Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]*'
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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/",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions infection.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@
"stryker": {
"report": "1.x"
}
},
"mutators": {
"@default": true,
"global-ignoreSourceCodeByRegex": [
"#\\[TestInline"
]
}
}
39 changes: 39 additions & 0 deletions plugin/error-handler/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
20 changes: 20 additions & 0 deletions plugin/error-handler/src/CapturedError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Testo\ErrorHandler;

/**
* A single PHP error captured during test execution.
*
* @api
*/
final readonly class CapturedError
{
public function __construct(
public int $severity,
public string $message,
public string $file,
public int $line,
) {}
}
37 changes: 37 additions & 0 deletions plugin/error-handler/src/ErrorHandlerPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Testo\ErrorHandler;

use Internal\Container\Container;
use Testo\Common\PluginConfigurator;
use Testo\ErrorHandler\Internal\ErrorHandlerInterceptor;
use Testo\Pipeline\InterceptorCollector;

/**
* 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
*/
Comment on lines +12 to +20
final readonly class ErrorHandlerPlugin implements PluginConfigurator
{
/**
* @param bool $failOnError When true, a captured PHP error fails the test. When false
* (default) errors are collected but the test result is unchanged.
*/
public function __construct(
private bool $failOnError = false,
) {}

#[\Override]
public function configure(Container $container): void
{
$container->get(InterceptorCollector::class)
->addInterceptor(new ErrorHandlerInterceptor($this->failOnError));
}
}
29 changes: 29 additions & 0 deletions plugin/error-handler/src/Internal/CapturedErrors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Testo\ErrorHandler\Internal;

use Testo\ErrorHandler\CapturedError;

/**
* Collection of PHP errors accumulated during test execution.
*
* 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
*/
final readonly class CapturedErrors
{
/** @param list<CapturedError> $errors */
public function __construct(
public array $errors,
) {}

public function isEmpty(): bool
{
return $this->errors === [];
}
}
70 changes: 70 additions & 0 deletions plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Testo\ErrorHandler\Internal;

use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Core\Value\Status;
use Testo\ErrorHandler\CapturedError;
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Pipeline\Middleware\TestRunInterceptor;

/**
* Intercepts PHP errors raised during test execution.
*
* Registers a custom error handler via {@see \set_error_handler()} before the test runs and
* restores the previous handler afterward via {@see \restore_error_handler()}. Accumulated
* errors are stored in the returned {@see TestResult} as a {@see CapturedErrors} attribute.
*
* @internal
* @psalm-internal Testo\ErrorHandler
*/
#[InterceptorOptions(order: InterceptorOptions::ORDER_CLOSE_TO_TEST)]
final readonly class ErrorHandlerInterceptor implements TestRunInterceptor
{
/**
* @param bool $failOnError When true, any captured error upgrades a passing test to
* {@see Status::Failed} with the first error wrapped in an
* {@see \ErrorException} as the failure.
*/
public function __construct(
private bool $failOnError = false,
) {}

#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
/** @var list<CapturedError> $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));
}
Comment on lines +61 to +66

return $result;
}
}
Loading
Loading