From f3bb57784a7fee790912c761b1f36809ac078cb4 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 14:16:21 +0100 Subject: [PATCH 1/6] fix(infection): skip mutations to #[TestInline] attribute arguments (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Infection mutates values inside a #[TestInline(arguments: [...], result: ...)] attribute it is mutating test data, not production logic. Every such mutation is "killed" (Assert::same catches the wrong expected value), but the kills are semantically meaningless — they verify that the assertion works, not that the source code handles a mutation correctly. This inflates the mutation score with noise and wastes CI runner time. Add `global-ignoreSourceCodeByRegex` to infection.json so Infection skips any mutation whose source line contains `#[TestInline`. This covers all single-line attribute declarations in both the Self-test fixtures under plugin/inline/tests/Self/ and any production code that uses the attribute. Method bodies on adjacent lines are unaffected and continue to be mutated. Co-Authored-By: Claude Sonnet 4.6 --- infection.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infection.json b/infection.json index 14366950..ab688144 100644 --- a/infection.json +++ b/infection.json @@ -17,5 +17,10 @@ "stryker": { "report": "1.x" } + }, + "mutators": { + "global-ignoreSourceCodeByRegex": [ + "#\\[TestInline" + ] } } From c40a136549a19b2536cd2ec4f93c60a5c0ec741c Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 13:48:44 +0100 Subject: [PATCH 2/6] fix(phpunit-mirror): add .placeholder.php so EmptyRun stub directory is mirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/Application/Stub/EmptyRun/ is intentionally empty — it is the test fixture for EmptyRunTest, which asserts that a Testo run over an empty directory yields Status::Risky with zero tests collected. Git does not track empty directories, and bin/build-phpunit.php only copies *.php files when populating the tests/PhpUnit/ mirror, so the mirror never contained tests/PhpUnit/Application/Stub/EmptyRun/. The mirrored EmptyRunTest resolved __DIR__ . '/../../Stub/EmptyRun' to that missing path and threw InvalidArgumentException: File or directory not found — aborting Infection's initial PHPUnit test run on every CI push to 1.x. Add .placeholder.php (no namespace, no classes, no tests) to the source directory. The build script copies it verbatim into the mirror, which creates the required directory. Testo's FinderConfig still discovers zero tests there, so Status::Risky is reported and the assertion holds. Co-Authored-By: Claude Sonnet 4.6 --- tests/Application/Stub/EmptyRun/.placeholder.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/Application/Stub/EmptyRun/.placeholder.php diff --git a/tests/Application/Stub/EmptyRun/.placeholder.php b/tests/Application/Stub/EmptyRun/.placeholder.php new file mode 100644 index 00000000..72680edf --- /dev/null +++ b/tests/Application/Stub/EmptyRun/.placeholder.php @@ -0,0 +1,10 @@ + Date: Mon, 6 Jul 2026 14:28:27 +0100 Subject: [PATCH 3/6] fix(infection): re-enable @default mutators alongside global-ignoreSourceCodeByRegex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifying "mutators": {} without "@default" treats the block as an allowlist, so all default mutators were silently disabled — producing 0 mutations and an MSI failure. Adding "@default": true restores the full default mutator set while the global regex filter still skips lines containing #[TestInline. Co-Authored-By: Claude Sonnet 4.6 --- infection.json | 1 + 1 file changed, 1 insertion(+) diff --git a/infection.json b/infection.json index ab688144..56f5a96c 100644 --- a/infection.json +++ b/infection.json @@ -19,6 +19,7 @@ } }, "mutators": { + "@default": true, "global-ignoreSourceCodeByRegex": [ "#\\[TestInline" ] From 841aa25eea6a304a9a70f8976d658aeabaa57172 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 14:47:56 +0100 Subject: [PATCH 4/6] feat(error-handler): add ErrorHandlerInterceptor plugin (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the error handler interceptor described in issue #73. The plugin wraps each test in set_error_handler() / restore_error_handler() and accumulates any PHP errors triggered during the test into a CapturedErrors attribute on the returned TestResult. Behaviour: - Default (failOnError: false): errors are collected and stored as a CapturedErrors attribute; the test result status is unchanged. - failOnError: true: a captured error upgrades a passing test to Status::Failed and wraps the first error in an ErrorException as the failure, preserving any pre-existing failure from the next() chain. Includes 10 unit tests covering collect mode, fail mode, multiple errors, first-error-wins semantics, and handler restoration (both normal and throw paths). All tests use zero-param closures for set_error_handler callbacks to avoid SonarQube S1172 (unused parameter) — PHP silently discards extra arguments when a callable declares fewer params than the caller passes. Also wires the plugin into the monorepo: composer.json (require + autoload-dev + path-repository version), testo.php (src exclusion + suites), and split-publish.yml (error-handler-[0-9]* tag). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/split-publish.yml | 1 + composer.json | 3 + plugin/error-handler/composer.json | 39 +++ plugin/error-handler/src/CapturedError.php | 20 ++ .../error-handler/src/ErrorHandlerPlugin.php | 37 +++ .../src/Internal/CapturedErrors.php | 29 +++ .../src/Internal/ErrorHandlerInterceptor.php | 70 ++++++ .../Unit/ErrorHandlerInterceptorTest.php | 223 ++++++++++++++++++ plugin/error-handler/tests/suites.php | 15 ++ testo.php | 2 + 10 files changed, 439 insertions(+) create mode 100644 plugin/error-handler/composer.json create mode 100644 plugin/error-handler/src/CapturedError.php create mode 100644 plugin/error-handler/src/ErrorHandlerPlugin.php create mode 100644 plugin/error-handler/src/Internal/CapturedErrors.php create mode 100644 plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php create mode 100644 plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php create mode 100644 plugin/error-handler/tests/suites.php 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/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 @@ + Date: Thu, 13 Aug 2026 16:08:22 +0100 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php index 3886f294..7d868295 100644 --- a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php +++ b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php @@ -58,7 +58,7 @@ static function (int $severity, string $message, string $file, int $line) use (& $result = $result->withAttribute(CapturedErrors::class, new CapturedErrors($errors)); - if ($this->failOnError && !$result->status->isFailure()) { + if ($this->failOnError && $result->status === Status::Passed) { $first = $errors[0]; $result = $result ->with(status: Status::Failed) From e8a70084521e12bc20cfc5faddb65f4cd27d69a9 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Thu, 13 Aug 2026 17:49:23 +0100 Subject: [PATCH 6/6] fix(error-handler): make ErrorHandlerInterceptor fiber-safe; promote CapturedErrors to public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses roxblnfk's review on #262: - set_error_handler()/restore_error_handler() operate on one process-global stack. The old code installed its handler once before $next() and restored once after — but $next() can suspend a fiber mid-test while a sibling test interleaves, so the handler stayed installed (and, on an interleaved resume, restore_error_handler() could pop a sibling's frame instead of its own). Same defect as #254. Fixed by wrapping $next() in its own fiber and swapping the handler on every suspend/resume — restore (native stack pop) on suspend, reinstall on resume — mirroring the already-reviewed pattern in MockeryInterceptor::run() and MessengerHub::scope(). Regression test added (restoresTheOuterHandlerWhileSuspendedAndReinstallsItsOwnOnResume): confirmed it fails against the old code (an error fired while suspended was wrongly captured by this test's own handler instead of reaching the outer one) and passes against the fix. - Promoted Internal\CapturedErrors to a public Testo\ErrorHandler\CapturedErrors class (Copilot review comment): the plugin's own docs already tell consumers to read this attribute off TestResult, so it was never really internal — it just wasn't marked as such. Fixes the @api-marked ErrorHandlerPlugin's docblock referencing an internal type. - The other Copilot comment (restrict the failOnError status upgrade to Status::Passed) was already fixed in a prior commit on this branch — no change needed. Also rebased onto current 1.x (26 commits behind), resolving one real conflict in composer.json (version bumps landed upstream since this PR opened) and the same CaseDefinition/CaseInfo required-argument fix already applied on #264. The second question from review — what should happen if a test changes the error handler itself mid-run — is intentionally left open; per roxblnfk's own comment it needs research/discussion before implementing, not a quick fix. Verified: - composer rector:ci: clean, 0 files - Full Testo suite: 1682 passed, 6 failed/7 error (same pre-existing Bench/Self baseline as the current rector/* PR series, unrelated) - ErrorHandlerInterceptorTest: 11/11 passed, including the new fiber-safety regression test (confirmed it fails against the old code) - Psalm: this repo's Psalm CI only covers core/ (confirmed via psalm.xml and psalm.yml's trigger paths) — plugin/error-handler/ was never in scope, unchanged by this fix Co-Authored-By: Claude Sonnet 5 --- .../src/{Internal => }/CapturedErrors.php | 7 +- .../error-handler/src/ErrorHandlerPlugin.php | 2 +- .../src/Internal/ErrorHandlerInterceptor.php | 64 +++++++++++++++---- .../Unit/ErrorHandlerInterceptorTest.php | 59 ++++++++++++++++- 4 files changed, 112 insertions(+), 20 deletions(-) rename plugin/error-handler/src/{Internal => }/CapturedErrors.php (80%) diff --git a/plugin/error-handler/src/Internal/CapturedErrors.php b/plugin/error-handler/src/CapturedErrors.php similarity index 80% rename from plugin/error-handler/src/Internal/CapturedErrors.php rename to plugin/error-handler/src/CapturedErrors.php index ce7304ea..982dc5d9 100644 --- a/plugin/error-handler/src/Internal/CapturedErrors.php +++ b/plugin/error-handler/src/CapturedErrors.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace Testo\ErrorHandler\Internal; - -use Testo\ErrorHandler\CapturedError; +namespace Testo\ErrorHandler; /** * Collection of PHP errors accumulated during test execution. @@ -12,8 +10,7 @@ * Stored as a {@see \Testo\Core\Context\TestResult} attribute under the key {@see CapturedErrors::class}. * Renderers that wish to display collected errors should retrieve it from the result. * - * @internal - * @psalm-internal Testo\ErrorHandler + * @api */ final readonly class CapturedErrors { diff --git a/plugin/error-handler/src/ErrorHandlerPlugin.php b/plugin/error-handler/src/ErrorHandlerPlugin.php index 3ac1c06d..9c40ba38 100644 --- a/plugin/error-handler/src/ErrorHandlerPlugin.php +++ b/plugin/error-handler/src/ErrorHandlerPlugin.php @@ -12,7 +12,7 @@ /** * Plugin that captures PHP errors raised during test execution. * - * By default errors are collected and stored as a {@see Internal\CapturedErrors} attribute + * By default errors are collected and stored as a {@see CapturedErrors} attribute * on the {@see \Testo\Core\Context\TestResult}, but the test still passes. Pass * {@see $failOnError}: true to make any captured error fail the test instead. * diff --git a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php index 7d868295..8e056c27 100644 --- a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php +++ b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php @@ -8,6 +8,7 @@ use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; use Testo\ErrorHandler\CapturedError; +use Testo\ErrorHandler\CapturedErrors; use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestRunInterceptor; @@ -39,18 +40,12 @@ public function runTest(TestInfo $info, callable $next): TestResult /** @var list $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; - }, - ); + $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(); - } + $result = $this->run($info, $next, $handler); if ($errors === []) { return $result; @@ -67,4 +62,51 @@ static function (int $severity, string $message, string $file, int $line) use (& 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 index fec8d021..c3e1072b 100644 --- a/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php +++ b/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php @@ -4,16 +4,18 @@ namespace Tests\ErrorHandler\Unit; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Definition\TestDefinition; use Testo\Core\Value\Status; use Testo\ErrorHandler\CapturedError; -use Testo\ErrorHandler\Internal\CapturedErrors; +use Testo\ErrorHandler\CapturedErrors; use Testo\ErrorHandler\Internal\ErrorHandlerInterceptor; use Testo\Test; @@ -207,11 +209,62 @@ public function handlerIsRestoredEvenWhenTestThrows(): void 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'); - $caseInfo = new CaseInfo(definition: $caseDefinition); + $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(