feat(error-handler): add ErrorHandlerInterceptor plugin - #262
feat(error-handler): add ErrorHandlerInterceptor plugin#262rossaddison wants to merge 17 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Implementation summary —
|
| File | Purpose |
|---|---|
src/CapturedError.php |
Value object — severity, message, file, line for one PHP error |
src/Internal/CapturedErrors.php |
Collection stored as a TestResult attribute under CapturedErrors::class |
src/Internal/ErrorHandlerInterceptor.php |
TestRunInterceptor at ORDER_CLOSE_TO_TEST priority — installs/restores handler, attaches attribute, optionally fails the test |
src/ErrorHandlerPlugin.php |
PluginConfigurator users add to their ApplicationConfig |
tests/Unit/ErrorHandlerInterceptorTest.php |
10 unit tests (all passing) |
Behaviour
| Mode | Result |
|---|---|
new ErrorHandlerPlugin() (default) |
Errors collected; test status unchanged; renderers may inspect CapturedErrors attribute |
new ErrorHandlerPlugin(failOnError: true) |
First captured error upgrades a passing test to Status::Failed with an ErrorException; pre-existing Failed/Error results are not overridden |
restore_error_handler() is always called in a finally block — the previous handler is restored even when the test pipeline throws.
Test coverage
| Test | What it verifies |
|---|---|
noErrorsPassesResultThrough |
No errors → result unmodified, no attribute |
capturedErrorIsStoredAsAttribute |
Single error → correct message and severity |
multipleErrorsAreAllCaptured |
All triggered errors appear in order |
collectModePreservesPassingStatus |
Default mode keeps Status::Passed |
failModeUpgradesPassingTestToFailed |
failOnError: true → Status::Failed + ErrorException |
failModeUsesFirstErrorAsFailure |
First error wins as the failure, not the last |
failModeDoesNotOverrideAlreadyFailedTest |
Pre-existing Status::Failed + original throwable preserved |
failModeDoesNotOverrideErrorStatus |
Pre-existing Status::Error preserved |
handlerIsRestoredAfterTestCompletes |
Outer handler is active again after a normal run |
handlerIsRestoredEvenWhenTestThrows |
Outer handler is active again even when the pipeline throws |
Note on zero-param closures in tests
The outer-handler closures in the restoration tests use static function () use (&$count) with no declared parameters. PHP silently discards extra arguments when a callable declares fewer params than the caller passes — standard PHP behaviour — and the zero-param form avoids SonarQube S1172 (unused parameter) without needing suppress annotations or $_-prefix workarounds.
Monorepo wiring
composer.json—require,autoload-dev, path-repository version entrytesto.php— src exclusion +suites.phpinclude.github/workflows/split-publish.yml—error-handler-[0-9]*tag for subtree split on release
There was a problem hiding this comment.
Pull request overview
Adds a new testo/error-handler plugin package that intercepts PHP errors during test execution, records them on TestResult, and optionally turns captured errors into test failures—integrating it into the monorepo (Composer wiring, suite registration, and split-publish tagging).
Changes:
- Introduces
ErrorHandlerInterceptor+ value objects (CapturedError,CapturedErrors) to capture and persist PHP errors raised during a test run. - Adds an
ErrorHandlerPluginconfigurator to register the interceptor (with afailOnErroroption). - Wires the new plugin into the monorepo: root Composer requirement + path version mapping, Testo suites inclusion, Infection config tweak, and split-publish tag pattern.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Application/Stub/EmptyRun/.placeholder.php | Adds an empty fixture file to ensure the stub directory exists in mirrored test layouts. |
| testo.php | Registers the new plugin test suite location and includes its suites.php. |
| plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php | Adds unit tests for capturing/accumulating errors and handler restoration behavior. |
| plugin/error-handler/tests/suites.php | Declares the ErrorHandler unit test suite for Testo’s suite discovery. |
| plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php | Implements the interceptor that installs/restores an error handler and records errors on the result. |
| plugin/error-handler/src/Internal/CapturedErrors.php | Adds the collection type used to store captured errors on TestResult. |
| plugin/error-handler/src/ErrorHandlerPlugin.php | Adds the public plugin configurator that registers the interceptor. |
| plugin/error-handler/src/CapturedError.php | Adds the value object representing a single captured PHP error. |
| plugin/error-handler/composer.json | Introduces the standalone package metadata and autoloading for the new plugin. |
| infection.json | Updates Infection mutator configuration to ignore inline-test source patterns. |
| composer.json | Adds the new plugin to root dependencies and monorepo package version mapping/autoload-dev. |
| .github/workflows/split-publish.yml | Extends subtree split publishing to recognize error-handler-* tags. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Plugin that captures PHP errors raised during test execution. | ||
| * | ||
| * By default errors are collected and stored as a {@see Internal\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. | ||
| * | ||
| * @api | ||
| */ |
roxblnfk
left a comment
There was a problem hiding this comment.
Thanks for the time you're putting in.
I see you're leaning on AI heavily. So am I — but opening raw AI output as a PR without a human review pass is dismissive of the reviewer's time.
This PR has the same defect as #254: process-global state that isn't fiber-safe. The error-handler stack is process-global and tests can run inside fibers — when a test suspends, its handler stays on the stack while another runs, so errors leak into the wrong test's CapturedErrors, and interleaved resume makes restore_error_handler() pop the wrong frame. The fix is the scope-swap already in MockeryInterceptor::run() / MessengerHub::scope(): inside a fiber, restore the previous handler on suspend and re-install the test's on resume.
A second, open question: a test may change the error handler itself during the run. The interceptor should notice that and react — but the right behavior isn't obvious, so it's worth researching and discussing before implementing.
If you want to contribute to the project, I ask you to:
- carry the fixes from earlier reviews of your PRs into future ones;
- understand the issue first, and ask questions before implementing if you have any;
- validate the AI agent's output yourself. That part is the contributor's, not the reviewer's.
…CapturedErrors to public Addresses roxblnfk's review on php-testo#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 php-testo#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 php-testo#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 <noreply@anthropic.com>
78c4846 to
e8a7008
Compare
|
Thanks for the detailed review — addressed both points, and validated the AI-generated fix myself rather than just pasting it. Fiber-safety fix
Added a regression test, The second question you raised — what should happen if a test changes the error handler itself mid-run — I've left alone. You flagged it as needing research/discussion before implementing, so I'm not guessing at behavior here; happy to pick it up separately once there's a direction. Copilot's comments
AlsoRebased onto current Verification
|
There was a problem hiding this comment.
Please remove all unrelated changes from other PRs
…is mirrored 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 <noreply@anthropic.com>
Implements the error handler interceptor described in issue php-testo#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 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…CapturedErrors to public Addresses roxblnfk's review on php-testo#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 php-testo#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 php-testo#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 <noreply@anthropic.com>
Rebasing feat/73-error-handler-interceptor onto the current 1.x tip (21 commits ahead) also dropped this branch's two unrelated infection.json commits (f3bb577, baa0449 -- neither is on 1.x; per roxblnfk's own review comment, they belong to a different concern and don't belong in this PR). That rebase's automatic 3-way merge for composer.json's `require` block produced literal duplicate keys instead of a real conflict: this branch's own commit had pinned older testo/* version constraints than 1.x has since moved to, and git treated the two blocks as independent additions rather than the same keys needing resolution, since nothing else nearby differed enough to force a conflict marker. The file was syntactically valid JSON throughout (PHP's json_decode silently keeps only the last occurrence of a duplicate key), but genuinely contained two require blocks side by side -- confirmed by grepping the working tree, not assumed from the diff alone. Fixed by keeping the single already-current-on-1.x block and inserting only the one genuinely new line this PR adds ("testo/error-handler": "^0.1") into it, in its original relative position. Verified: valid JSON, no duplicate keys (checked programmatically), composer install succeeds, and the ErrorHandler/Unit suite still passes 11/11 (including the fiber-safety regression test) against the rebased tree. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
86a9ff0 to
731a6ed
Compare
|
Rebased onto current
Current diff is 11 files, no The open question from review (what should happen if a test changes the error handler itself mid-run) is still intentionally unaddressed — agreed it needs discussion first, not a quick fix. Ready for another look whenever you have time. |
… to constraints The PHPUnit mirror already keeps the empty stub directory alive through its .gitkeep, so the placeholder duplicated that fix and put a PHP file into a fixture meant to be empty. The remaining comments now state only the fiber constraint on the handler stack; tooling notes and the history of the fix are gone. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…alled by the test Four tests fail against the current interceptor. Errors under @ or outside error_reporting() are captured and fail the test, because the handler never consults the reporting mask. A test that installs its own handler without restoring it makes restore_error_handler() pop that handler instead of ours, so ours stays on the stack: it shadows the outer handler after the test and, inside a fiber, keeps capturing sibling errors while the test is suspended. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…he ExpectErrorHandlerChange contract Errors must still reach the handler that was installed before the test. A test that leaves its own handler behind or removes ours is Risky unless it declares the change with the attribute; a declared change that never happens is Risky too. Inside a fiber the handlers a test installed above ours must come back with it on resume. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce the handler stack per test feat(error-handler): add #[ExpectErrorHandlerChange] Errors under @ or outside error_reporting() are no longer captured, and every error is forwarded to the handler that was installed before the test, so the plugin observes rather than replaces the application's handler. The test's slice of the handler stack (our handler plus anything the test installs above it) leaves with the test on a fiber suspension and comes back on resumption, so a handler the test installed is in place when it continues and absent while a sibling runs. After the test the stack is put back as found; a passing test that changed it is Risky, unless it declares the change with the attribute, in which case an unchanged stack fails the test instead. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…e PHPUnit run PHPUnit masks error_reporting() down to fatal levels for the duration of a test, so the interceptor under test treats every triggered error as silenced and the mirrored tests cannot pass by construction. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
A previous handler that throws turns the error into control flow of the test, so nothing must be captured for it; otherwise a test that correctly expects that exception is failed by failOnError. Also pins that the previous handler observes the real error_reporting() level. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…d E_USER_ERROR Errors nobody handled go to the stderr channel the way PHP would print them, and PHP's own printing is suppressed. An error the previous handler took (returned true) is captured with handled=true, stays out of stderr, and still fails the test under failOnError. E_USER_ERROR is thrown as an ErrorException rather than swallowed. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…el and flag handled ones The captured list keeps every error the previous handler did not turn into an exception, with handled telling whether that handler took it. Only the errors nobody handled go to the stderr channel, formatted as PHP prints them, and our handler always returns true so PHP does not print them a second time. E_USER_ERROR is thrown as an ErrorException: left to PHP it ends the script, so swallowing it would run the test past a fatal. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
… form non-empty Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…g skills by direction Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
…ible in both directions #[ExpectErrorHandlerChange] waives a risky verdict PHPUnit applies unconditionally, and #[WithoutErrorHandler] opts out of a runner handler Testo does not install; neither maps onto the other. Stub rules document the gap, not registered. Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
🔍 What was changed
New
testo/error-handlerplugin (plugin/error-handler).ErrorHandlerPluginwraps every test in an error handler and records what PHP raised while the test ran.TestResultas aCapturedErrorsattribute (severity,message,file,line,handled). Errors nobody handled are also written to thestderrmessage channel in PHP's own wording, and PHP's own printing is suppressed so nothing appears twice.new ErrorHandlerPlugin(failOnError: true)turns the first captured error into anErrorExceptionfailure of a passing test. Errors silenced with@or excluded byerror_reporting()are neither captured nor failed.error_reporting()level, and an exception it throws stays the test's own control flow with nothing captured.E_USER_ERRORis thrown as anErrorExceptionin every mode instead of being swallowed.Riskywith the reason in theerror-handlermessage channel.#[ExpectErrorHandlerChange](class, method or function) declares such a test intentional; a marked test that leaves the stack unchanged fails withErrorHandlerUnchanged.How it works
await.E_USER_ERROR, then record unless the reporting mask excludes the severity, and returntrue.Review notes
error_reporting()down to fatal levels while a test runs, so the mirrored plugin tests see every error as silenced.tools/phpunit/phpunit.xmlexcludestests/PhpUnit/ErrorHandlerfrom the mirror run.Checklist
ErrorHandler/Unit, 32 tests)composer phpunitmirror green after the exclusionplugin/error-handlerphp-testo/error-handlerrepository,.github/.release-please-config.jsonentry and Packagist registration, otherwise the rootcomposer.jsonrequirement is unresolvable outside the monorepoREADME.mdandCHANGELOG.mdDocumentation
skills/testo-php-errors/SKILL.mddocuments the plugin, the error table and#[ExpectErrorHandlerChange];docs/spec/skills-regrouping.mdschedules folding it into a direction skill.