From 9e30d5e1072b07a7efd13f1189f361c2291e6611 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 14:16:21 +0100 Subject: [PATCH 1/4] 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 05a3e7793df516307cbe26813b2767cb79fe4804 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 13:48:44 +0100 Subject: [PATCH 2/4] 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/4] 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 4c1af2a931a9d9d8b1cead817517acbf5f81e4e7 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 14:47:56 +0100 Subject: [PATCH 4/4] 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 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/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 @@ +