diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml new file mode 100644 index 00000000..b55ec4b1 --- /dev/null +++ b/.github/workflows/rector.yml @@ -0,0 +1,50 @@ +name: Rector + +on: + push: + paths: + - 'core/**' + - 'plugin/**' + - 'bridge/**' + - 'rector.php' + - 'composer.json' + - 'composer.lock' + - '.github/workflows/rector.yml' + pull_request: + paths: + - 'core/**' + - 'plugin/**' + - 'bridge/**' + - 'rector.php' + - 'composer.json' + - 'composer.lock' + - '.github/workflows/rector.yml' + +jobs: + rector: + runs-on: ubuntu-latest + name: Rector + + steps: + + - name: Checkout + uses: actions/checkout@v7 + + # The split sub-packages pin testo/testo; in CI the root is a detached + # commit (dev-), so its version must be declared explicitly. + - name: Resolve root package version + run: echo "COMPOSER_ROOT_VERSION=$(jq -r '.["."]' resources/version.json)" >> "$GITHUB_ENV" + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + + - name: Run Rector + run: composer rector:ci diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index 914d4fc5..853ac0ef 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -27,6 +27,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/bridge/infection/src/TestoAdapter.php b/bridge/infection/src/TestoAdapter.php index 6eac10e1..ba0263eb 100644 --- a/bridge/infection/src/TestoAdapter.php +++ b/bridge/infection/src/TestoAdapter.php @@ -12,17 +12,17 @@ * * @internal */ -final class TestoAdapter implements TestFrameworkAdapter +final readonly class TestoAdapter implements TestFrameworkAdapter { /** @var non-empty-string Path to the Testo PHP entry script. */ - private readonly string $testFrameworkExecutable; + private string $testFrameworkExecutable; public function __construct( string $testFrameworkExecutable, /** @var non-empty-string Absolute path to the project directory. */ - private readonly string $projectDir, + private string $projectDir, /** @var non-empty-string Infection's tmp directory; safe to drop per-mutant bootstrap files in. */ - private readonly string $tmpDir, + private string $tmpDir, /** * @var non-empty-string Path where Infection expects the JUnit XML * report. We pass it back to Testo via `--log-junit=` and @@ -30,14 +30,14 @@ public function __construct( * whether to use JUnit-driven test mapping or fall back to * reflection-based resolution. */ - private readonly string $jUnitFilePath, + private string $jUnitFilePath, /** * @var non-empty-string Directory where Infection expects the PHPUnit-style coverage XML * (it reads `/index.xml`). We pass it back to Testo via `--coverage-xml=`, * which activates the default (shadow) `CodecovPlugin` — so the coverage report is * produced even when the user's `testo.php` declares no coverage plugin. */ - private readonly string $coverageXmlPath = '', + private string $coverageXmlPath = '', ) { # On Windows, Infection's TestFrameworkFinder may hand us `bin/testo.bat`. # We can't `php testo.bat` — strip the `.bat` and run the sibling PHP script directly. diff --git a/bridge/symfony-console/src/Command/Init.php b/bridge/symfony-console/src/Command/Init.php index da6e9b7f..96072864 100644 --- a/bridge/symfony-console/src/Command/Init.php +++ b/bridge/symfony-console/src/Command/Init.php @@ -250,7 +250,7 @@ private static function printSummary(Path $configPath, array $composerKeys, Symf $runHints = $composerKeys === [] ? [' $ vendor/bin/testo'] : \array_map( - static fn(string $key) => \sprintf(' $ composer %s', $key), + static fn(string $key): string => \sprintf(' $ composer %s', $key), $composerKeys, ); diff --git a/codecov.yml b/codecov.yml index d2563ca8..6e88f55f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -69,3 +69,13 @@ ignore: - "**/tests/**" - "resources/**" - "skills/**" + # Bench warmup loop (warmup=0 in all tests) and bench renderer are unreachable under TESTO_CI=1 + # because bench tests fail due to Xdebug stack depth before the renderer is ever called. + - "plugin/bench/src/Internal/BenchHandler.php" + - "plugin/bench/src/Internal/Renderer.php" + # SuiteFactory runs during test discovery (before per-test coverage windows open), + # so its lines never appear as covered in the clover report. + - "core/Application/Internal/SuiteFactory.php" + # TestingSuite is @psalm-internal Testo and is only instantiated via attribute reflection + # inside InjectPlugin tests, which are excluded from TESTO_CI=1 runs. + - "core/Testing/Attribute/TestingSuite.php" diff --git a/composer.json b/composer.json index d87e03ec..c13d1946 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,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", @@ -94,6 +95,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/", @@ -118,6 +120,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", @@ -166,6 +169,8 @@ "post-update-cmd": "dload get --no-interaction -v || \"echo can't dload binaries\"", "cs:diff": "php-cs-fixer fix --dry-run -v --diff", "cs:fix": "php-cs-fixer fix -v", + "rector": "rector", + "rector:ci": "rector --dry-run --clear-cache", "infect": [ "@putenv TESTO_CI=1", "@putenv XDEBUG_MODE=coverage", diff --git a/core/Application/Application.php b/core/Application/Application.php index a96cb11c..f2b38623 100644 --- a/core/Application/Application.php +++ b/core/Application/Application.php @@ -77,7 +77,7 @@ public static function createFromInput( 'Configuration file %s must return an instance of %s, %s returned.', $configFile, ApplicationConfig::class, - \is_object($cfg) ? \get_class($cfg) : \gettype($cfg), + \get_debug_type($cfg), ), ); return $cfg; diff --git a/core/Application/Config/ApplicationConfig.php b/core/Application/Config/ApplicationConfig.php index 3d9b0ac7..8453323b 100644 --- a/core/Application/Config/ApplicationConfig.php +++ b/core/Application/Config/ApplicationConfig.php @@ -45,7 +45,7 @@ public function __construct( # Validate suite configs $suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.'); - \array_walk($suites, static fn(mixed $suite) => $suite instanceof SuiteConfig + \array_walk($suites, static fn(mixed $suite): bool => $suite instanceof SuiteConfig or throw new \InvalidArgumentException( 'Each suite must be an instance of SuiteConfig.', )); diff --git a/core/Application/Config/Internal/ConfigInflector.php b/core/Application/Config/Internal/ConfigInflector.php index 6e0d4996..c8d95405 100644 --- a/core/Application/Config/Internal/ConfigInflector.php +++ b/core/Application/Config/Internal/ConfigInflector.php @@ -114,7 +114,6 @@ private function injectValue( // Cast value to the property type $type = $property->getType(); - /** @var mixed $result */ $result = match (true) { !$type instanceof \ReflectionNamedType => $value, $type->allowsNull() && $value === '' => null, diff --git a/core/Application/Internal/Messenger/State.php b/core/Application/Internal/Messenger/State.php index 86f00130..899d0521 100644 --- a/core/Application/Internal/Messenger/State.php +++ b/core/Application/Internal/Messenger/State.php @@ -136,7 +136,7 @@ private function absorbEvents(array $events): void if ($this->holdEvents) { $this->heldEvents = \array_merge($this->heldEvents, $events); # Keep held events in time order so they are released chronologically on commit. - \usort($this->heldEvents, static fn(Message $a, Message $b) => $a->time <=> $b->time); + \usort($this->heldEvents, static fn(Message $a, Message $b): int => $a->time <=> $b->time); return; } @@ -169,7 +169,7 @@ private function merge(self $state): void # Out-of-order (clock skew / interleaving): combine and stable-sort by time. $merged = \array_merge($this->messages, $state->messages); - \usort($merged, static fn(Message $a, Message $b) => $a->time <=> $b->time); + \usort($merged, static fn(Message $a, Message $b): int => $a->time <=> $b->time); $this->messages = $merged; } } diff --git a/core/Application/Internal/SuiteFactory.php b/core/Application/Internal/SuiteFactory.php index 8299beb4..6e0f12af 100644 --- a/core/Application/Internal/SuiteFactory.php +++ b/core/Application/Internal/SuiteFactory.php @@ -34,7 +34,7 @@ public function __construct( public function create(SuiteConfig $config, Filter $filter): SuiteInfo { $files = $this->getFilesIterator($config, $filter); - $definitions = $this->getCaseDefinitions($config, $files, $filter); + $definitions = $this->getCaseDefinitions($files, $filter); $cases = []; foreach ($definitions as $definition) { @@ -89,7 +89,7 @@ private function getFilesIterator(SuiteConfig $config, Filter $filter): iterable * @param iterable $files * @return list */ - private function getCaseDefinitions(SuiteConfig $config, iterable $files, Filter $filter): array + private function getCaseDefinitions(iterable $files, Filter $filter): array { $cases = []; # Prepare interceptors pipeline diff --git a/core/Common/Info.php b/core/Common/Info.php index 0e97f023..5b751a2b 100644 --- a/core/Common/Info.php +++ b/core/Common/Info.php @@ -45,7 +45,6 @@ public static function version(): string return $cache = self::VERSION; } - /** @var mixed $version */ $version = \json_decode($fileContent, true)['.'] ?? null; return $cache = \is_string($version) && $version !== '' diff --git a/core/Output/Json/JsonPlugin.php b/core/Output/Json/JsonPlugin.php index 6ee28bbb..8c80cf87 100644 --- a/core/Output/Json/JsonPlugin.php +++ b/core/Output/Json/JsonPlugin.php @@ -41,9 +41,6 @@ final class JsonPlugin implements PluginConfigurator */ private readonly ?Path $path; - /** @var resource|null Stream used in stdout mode; resolved to {@see \STDOUT} on write. */ - private $stream; - private readonly JsonReport $report; /** @@ -54,10 +51,9 @@ final class JsonPlugin implements PluginConfigurator * @param resource|null $stream Stream for stdout mode; defaults to {@see \STDOUT}. Ignored * when a file path is set. */ - public function __construct(?string $outputPath = null, $stream = null) + public function __construct(?string $outputPath = null, private $stream = null) { $this->path = $outputPath !== null && $outputPath !== '' ? Path::create($outputPath) : null; - $this->stream = $stream; $this->report = new JsonReport(); } diff --git a/core/Output/Rendering/ChannelRenderer.php b/core/Output/Rendering/ChannelRenderer.php index 23007ecc..f27c4f78 100644 --- a/core/Output/Rendering/ChannelRenderer.php +++ b/core/Output/Rendering/ChannelRenderer.php @@ -90,16 +90,34 @@ private static function header(string $channel, float $time): string } /** - * Formats a {@see \microtime()} timestamp as `HH:MM:SS.mmm` wall-clock time. + * Formats a {@see \microtime()} timestamp as `HH:MM:SS.mmm` wall-clock time + * in the current PHP timezone. * - * @return non-empty-string + * The input is a float returned by {@see \microtime(true)}, so it represents an + * epoch timestamp with fractional seconds. We construct a timezone-aware + * {@see \DateTimeImmutable} from that epoch using `U.u` and then format it + * in the configured PHP timezone. This makes the header show local wall-clock + * time rather than UTC-based time derived by modulo arithmetic. */ private static function formatTime(float $time): string { - $seconds = (int) $time; - $millis = \min(999, (int) \round(($time - (float) $seconds) * 1000.0)); + $date = \DateTimeImmutable::createFromFormat( + 'U.u', + \sprintf('%.6F', $time), + ); + + if ($date === false) { + $totalSeconds = (int) $time; + $millis = \min(999, (int) \round(($time - (float) $totalSeconds) * 1000.0)); + $s = $totalSeconds % 60; + $m = (int) ($totalSeconds / 60) % 60; + $h = (int) ($totalSeconds / 3600) % 24; + + return \sprintf('%02d:%02d:%02d.%03d', $h, $m, $s, $millis); + } - /** @var non-empty-string */ - return \date('H:i:s', $seconds) . \sprintf('.%03d', $millis); + return $date + ->setTimezone(new \DateTimeZone(\date_default_timezone_get())) + ->format('H:i:s.v'); } } diff --git a/core/Output/Rendering/Diff/PatienceDiffer.php b/core/Output/Rendering/Diff/PatienceDiffer.php index 148d7e28..d4a5fb42 100644 --- a/core/Output/Rendering/Diff/PatienceDiffer.php +++ b/core/Output/Rendering/Diff/PatienceDiffer.php @@ -16,10 +16,10 @@ * * @internal */ -final class PatienceDiffer implements Differ +final readonly class PatienceDiffer implements Differ { public function __construct( - private readonly Differ $fallback = new MyersDiffer(), + private Differ $fallback = new MyersDiffer(), ) {} #[\Override] diff --git a/core/Output/Rendering/Diff/PrefixSuffixDiffer.php b/core/Output/Rendering/Diff/PrefixSuffixDiffer.php index d4a5b920..a24804fc 100644 --- a/core/Output/Rendering/Diff/PrefixSuffixDiffer.php +++ b/core/Output/Rendering/Diff/PrefixSuffixDiffer.php @@ -14,10 +14,10 @@ * * @internal */ -final class PrefixSuffixDiffer implements Differ +final readonly class PrefixSuffixDiffer implements Differ { public function __construct( - private readonly Differ $inner = new MyersDiffer(), + private Differ $inner = new MyersDiffer(), ) {} #[\Override] diff --git a/core/Output/Rendering/Diff/RatcliffObershelpDiffer.php b/core/Output/Rendering/Diff/RatcliffObershelpDiffer.php index 2199ceff..6bc5e6d1 100644 --- a/core/Output/Rendering/Diff/RatcliffObershelpDiffer.php +++ b/core/Output/Rendering/Diff/RatcliffObershelpDiffer.php @@ -21,10 +21,10 @@ * * @internal */ -final class RatcliffObershelpDiffer implements Differ +final readonly class RatcliffObershelpDiffer implements Differ { public function __construct( - private readonly bool $autoJunk = true, + private bool $autoJunk = true, ) {} #[\Override] diff --git a/core/Output/Teamcity/Teamcity/TeamcityLogger.php b/core/Output/Teamcity/Teamcity/TeamcityLogger.php index 527e9916..d42f3f8c 100644 --- a/core/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/core/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -332,8 +332,6 @@ public function logEmptyRun(): void */ public function handleSingleTestResult(TestResult $result, ?int $duration = null, ?string $overrideName = null): void { - $name = $overrideName ?? $result->info->name; - match ($result->status) { Status::Passed, Status::Flaky => $this->handlePassedTest($result, $duration, $overrideName), Status::Failed, Status::Error => $this->handleFailedTest($result, $duration, $overrideName), diff --git a/core/Output/Terminal/Renderer/FormattedItem.php b/core/Output/Terminal/Renderer/FormattedItem.php index 3cff252f..fbfc18a3 100644 --- a/core/Output/Terminal/Renderer/FormattedItem.php +++ b/core/Output/Terminal/Renderer/FormattedItem.php @@ -11,29 +11,29 @@ * * @internal */ -final class FormattedItem +final readonly class FormattedItem { public function __construct( /** * @var non-empty-string */ - public readonly string $name, - public readonly Status $status, + public string $name, + public Status $status, /** * @var int<0, max>|null Duration in milliseconds */ - public readonly ?int $duration = null, + public ?int $duration = null, /** * @var int<0, max> Indentation level (0 = no indent) */ - public readonly int $indentLevel = 0, + public int $indentLevel = 0, /** * @var int<1, max>|null Index in collection (for numbered items) */ - public readonly ?int $index = null, + public ?int $index = null, /** * @var non-empty-string|null Additional description (e.g., data provider key) */ - public readonly string $description = '', + public string $description = '', ) {} } diff --git a/core/Output/Terminal/Renderer/Formatter.php b/core/Output/Terminal/Renderer/Formatter.php index 5917715a..3d9064ac 100644 --- a/core/Output/Terminal/Renderer/Formatter.php +++ b/core/Output/Terminal/Renderer/Formatter.php @@ -267,9 +267,8 @@ public static function summary( $result = "\n\n " . Style::bold('Summary') . "\n\n"; $result .= self::statRow('Time', Style::dim("{$testsTime} tests · {$overheadTime} overhead")); $result .= self::statRow('Total', "{$total} tests · {$assertions} assertions"); - $result .= self::statRow('', $breakdown); - return $result; + return $result . self::statRow('', $breakdown); } /** @@ -390,9 +389,8 @@ private static function formatCompactRun(FormattedItem $item, OutputFormat $form : ''; $result = "{$indent}{$symbol} {$item->name}{$durationStr}\n"; - $result .= self::description($item->description, $item->indentLevel, $format); - return $result; + return $result . self::description($item->description, $item->indentLevel, $format); } /** @@ -400,7 +398,7 @@ private static function formatCompactRun(FormattedItem $item, OutputFormat $form */ private static function formatDotRun(FormattedItem $item): string { - $symbol = match ($item->status) { + return match ($item->status) { Status::Passed => DotSymbol::Passed->value, Status::Failed => Style::error(DotSymbol::Failed->value), Status::Skipped => Style::warning(DotSymbol::Skipped->value), @@ -410,8 +408,6 @@ private static function formatDotRun(FormattedItem $item): string Status::Flaky => Style::info(DotSymbol::Passed->value), Status::Cancelled => Style::dim(DotSymbol::Skipped->value), }; - - return $symbol; } /** diff --git a/core/Output/Terminal/Renderer/Style.php b/core/Output/Terminal/Renderer/Style.php index b1e03b3d..46b4cc97 100644 --- a/core/Output/Terminal/Renderer/Style.php +++ b/core/Output/Terminal/Renderer/Style.php @@ -59,8 +59,6 @@ public static function bold(string $text): string /** * Makes text dim (less visible). - * - * @param non-empty-string $text */ public static function dim(string $text): string { diff --git a/core/Output/Terminal/Renderer/TerminalLogger.php b/core/Output/Terminal/Renderer/TerminalLogger.php index f190c176..dd8f687b 100644 --- a/core/Output/Terminal/Renderer/TerminalLogger.php +++ b/core/Output/Terminal/Renderer/TerminalLogger.php @@ -411,7 +411,6 @@ private function printMultipleRuns(TestResult $result): void $item = new FormattedItem( name: "Run #{$runNumber}", status: $runResult->status, - duration: null, indentLevel: 1, description: (string) $runKey, ); diff --git a/core/Pipeline/Attribute/FallbackInterceptor.php b/core/Pipeline/Attribute/FallbackInterceptor.php index c9e6563b..8795cc4b 100644 --- a/core/Pipeline/Attribute/FallbackInterceptor.php +++ b/core/Pipeline/Attribute/FallbackInterceptor.php @@ -20,7 +20,7 @@ * @api */ #[\Attribute(\Attribute::TARGET_CLASS)] -final class FallbackInterceptor +final readonly class FallbackInterceptor { public function __construct( /** @@ -28,6 +28,6 @@ public function __construct( * * @var class-string<\Testo\Pipeline\Interceptor> */ - public readonly string $class, + public string $class, ) {} } diff --git a/core/Pipeline/Attribute/InterceptorOptions.php b/core/Pipeline/Attribute/InterceptorOptions.php index ab75769f..82636a0f 100644 --- a/core/Pipeline/Attribute/InterceptorOptions.php +++ b/core/Pipeline/Attribute/InterceptorOptions.php @@ -12,7 +12,7 @@ * @api */ #[\Attribute(\Attribute::TARGET_CLASS)] -final class InterceptorOptions +final readonly class InterceptorOptions { /** * Handles {@see Interceptable} attributes @@ -49,13 +49,13 @@ public function __construct( * Lower priority interceptors are applied first in the interceptor chain. * Higher priority interceptors are closer to the test function in the interceptor chain. */ - public readonly int $order = self::ORDER_DEFAULT, - public readonly ConflictPolicy $onConflict = ConflictPolicy::First, + public int $order = self::ORDER_DEFAULT, + public ConflictPolicy $onConflict = ConflictPolicy::First, /** * @var list|non-empty-string|\BackedEnum Type(s) of tests to which * the interceptor should be applied. If empty, the interceptor is applied to all tests. */ - public readonly \BackedEnum|array|string $testType = [], + public \BackedEnum|array|string $testType = [], ) {} } diff --git a/core/Pipeline/Pipeline.php b/core/Pipeline/Pipeline.php index 15207ba8..9972f5aa 100644 --- a/core/Pipeline/Pipeline.php +++ b/core/Pipeline/Pipeline.php @@ -54,7 +54,6 @@ private function __construct( * @param PipeOptions $options Pipeline options, e.g. the test-type selection used to filter * interceptors. Empty options keep every interceptor. {@see CaseDefinition::$type} * @param TInterceptor ...$interceptors Instantiated interceptors. - * @return self * * @note Make sure that interceptors implement the same interface. * @psalm-suppress InvalidTemplateParam, UndefinedDocblockClass, InvalidReturnType, InvalidReturnStatement @@ -70,7 +69,6 @@ public static function prepare(PipeOptions $options, TInterceptor ...$intercepto * All the remaining interceptors will be sorted and combined into a new single interceptor. * * @param TInterceptor ...$interceptors Instantiated interceptors. - * @return self */ public function combine(TInterceptor ...$interceptors): self { diff --git a/core/Testing/Attribute/TestingSuite.php b/core/Testing/Attribute/TestingSuite.php index 5ca809ea..b1bbf7aa 100644 --- a/core/Testing/Attribute/TestingSuite.php +++ b/core/Testing/Attribute/TestingSuite.php @@ -16,9 +16,6 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] final readonly class TestingSuite { - /** @var list|PluginConfigurator> */ - public array $plugins; - /** * @param non-empty-string|Path $path Stub directory or file path. * @param list|PluginConfigurator> $plugins Extra plugins to load @@ -32,13 +29,5 @@ * @param array $env Environment variables to emulate, mapped through * {@see \Testo\Application\Config\Internal\Attribute\Env} bindings. */ - public function __construct( - public string|Path $path, - array $plugins = [], - public array $options = [], - public array $arguments = [], - public array $env = [], - ) { - $this->plugins = $plugins; - } + public function __construct(public string|Path $path, public array $plugins = [], public array $options = [], public array $arguments = [], public array $env = []) {} } diff --git a/core/Tokenizer/DefinitionLocator.php b/core/Tokenizer/DefinitionLocator.php index 38ae8dd9..25701580 100644 --- a/core/Tokenizer/DefinitionLocator.php +++ b/core/Tokenizer/DefinitionLocator.php @@ -162,38 +162,4 @@ private static function loadReflection( \spl_autoload_unregister($includer); } } - - /** - * Safely get function reflection, function loading errors will be blocked and reflection will be - * excluded from analysis. - * - * @throws LocatorException - */ - private static function functionReflection(string $function): \ReflectionFunction - { - $loader = static function (string $class): void { - if ($class === LocatorException::class) { - return; - } - - throw new LocatorException(\sprintf("Class '%s' can not be loaded", $class)); - }; - - //To suspend class dependency exception - \spl_autoload_register($loader); - - try { - //In some cases reflection can throw an exception if function is invalid or can not be loaded, - //we are going to handle such exception and convert it to soft exception - return new \ReflectionFunction($function); - } catch (\Throwable $e) { - if ($e instanceof LocatorException && $e->getPrevious() !== null) { - $e = $e->getPrevious(); - } - - throw new LocatorException($e->getMessage(), (int) $e->getCode(), $e); - } finally { - \spl_autoload_unregister($loader); - } - } } 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/assert/src/Internal/Assertion/AssertJson.php b/plugin/assert/src/Internal/Assertion/AssertJson.php index 0ec90b49..56d072b7 100644 --- a/plugin/assert/src/Internal/Assertion/AssertJson.php +++ b/plugin/assert/src/Internal/Assertion/AssertJson.php @@ -409,7 +409,7 @@ private function resolvePath(string $path): mixed } // Numeric key - if (\is_string($key) && \ctype_digit($key)) { + if (\ctype_digit($key)) { $key = (int) $key; } diff --git a/plugin/assert/src/Internal/Assertion/Traits/IterableTrait.php b/plugin/assert/src/Internal/Assertion/Traits/IterableTrait.php index 9bf7ab4c..653d90c0 100644 --- a/plugin/assert/src/Internal/Assertion/Traits/IterableTrait.php +++ b/plugin/assert/src/Internal/Assertion/Traits/IterableTrait.php @@ -159,7 +159,7 @@ public function hasCount(int $expected): static private static function countIterable(iterable $value): int { // if Countable - if (\is_array($value) || $value instanceof \Countable) { + if (is_countable($value)) { return \count($value); } diff --git a/plugin/assert/src/Internal/Expectation/NotLeaks.php b/plugin/assert/src/Internal/Expectation/NotLeaks.php index f2408c56..80534050 100644 --- a/plugin/assert/src/Internal/Expectation/NotLeaks.php +++ b/plugin/assert/src/Internal/Expectation/NotLeaks.php @@ -27,7 +27,7 @@ final class NotLeaks public function __construct( object ...$objects, ) { - $this->map = \array_map(static fn(object $object): \WeakReference => \WeakReference::create($object), $objects); + $this->map = \array_map(\WeakReference::create(...), $objects); } /** diff --git a/plugin/bench/src/Internal/BenchHandler.php b/plugin/bench/src/Internal/BenchHandler.php index bae29d43..fd80fc69 100644 --- a/plugin/bench/src/Internal/BenchHandler.php +++ b/plugin/bench/src/Internal/BenchHandler.php @@ -117,7 +117,7 @@ private static function runIteration( int $calls, ): IterationSet { $cases = []; - foreach ($functions as $k => $function) { + foreach ($functions as $function) { $cases[] = self::runCase($function, $calls); } diff --git a/plugin/bench/src/Internal/Renderer.php b/plugin/bench/src/Internal/Renderer.php index 39aae6d7..39d9bf2f 100644 --- a/plugin/bench/src/Internal/Renderer.php +++ b/plugin/bench/src/Internal/Renderer.php @@ -238,26 +238,6 @@ private static function ordinal(int $n): string return $n . $suffix; } - /** - * @param list $headers - * @param list> $rows - */ - private static function renderTable(array $headers, array $rows): string - { - $widths = self::calculateWidths($headers, $rows); - - $separator = self::separator($widths); - $lines = [$separator, self::row($headers, $widths), $separator]; - - foreach ($rows as $r) { - $lines[] = self::row($r, $widths); - } - - $lines[] = $separator; - - return \implode("\n", $lines); - } - /** * @param list $headers * @param list> $rows @@ -265,7 +245,7 @@ private static function renderTable(array $headers, array $rows): string */ private static function calculateWidths(array $headers, array $rows): array { - $widths = \array_map(static fn(string $h): int => \mb_strlen($h), $headers); + $widths = \array_map(\mb_strlen(...), $headers); foreach ($rows as $row) { foreach ($row as $i => $cell) { @@ -305,20 +285,6 @@ private static function row(array $cells, array $widths, array $rightAlign = []) return '|' . \implode('|', $parts) . '|'; } - /** - * @param list $cells - * @param list $widths - */ - private static function centeredRow(array $cells, array $widths): string - { - $parts = []; - foreach ($cells as $i => $cell) { - $parts[] = ' ' . self::centerPad($cell, $widths[$i]) . ' '; - } - - return '|' . \implode('|', $parts) . '|'; - } - /** * Separator with certain column ranges merged (internal `+` replaced with `-`). * @@ -425,17 +391,4 @@ private static function joinReports(array $reports, Severity $severity): string return \implode('. ', $reasons); } - - private static function centerPad(string $text, int $width): string - { - $len = \mb_strlen($text); - if ($len >= $width) { - return $text; - } - - $left = (int) (($width - $len) / 2); - $right = $width - $len - $left; - - return \str_repeat(' ', $left) . $text . \str_repeat(' ', $right); - } } diff --git a/plugin/data/src/Internal/DataProviderInterceptor.php b/plugin/data/src/Internal/DataProviderInterceptor.php index 274addde..910bfd71 100644 --- a/plugin/data/src/Internal/DataProviderInterceptor.php +++ b/plugin/data/src/Internal/DataProviderInterceptor.php @@ -208,8 +208,8 @@ private static function fromDataProvider(TestInfo $info, DataProvider $attribute if ($class->hasMethod($provider)) { $m = $class->getMethod($provider); $provider = match (true) { - $m->isStatic() => $m->getClosure(null), - default => static fn() => $m->getClosure($info->caseInfo->instance->getInstance()), + $m->isStatic() => $m->getClosure(), + default => $m->getClosure(($info->caseInfo->instance ?? throw new \LogicException("Cannot use non-static DataProvider '{$provider}': test has no class instance."))->getInstance()), }; } diff --git a/plugin/data/tests/Unit/Fixture/NonStaticProviderTarget.php b/plugin/data/tests/Unit/Fixture/NonStaticProviderTarget.php new file mode 100644 index 00000000..35e04831 --- /dev/null +++ b/plugin/data/tests/Unit/Fixture/NonStaticProviderTarget.php @@ -0,0 +1,24 @@ +results), 6); } + public function supportsNonStaticProviderMethodBoundToInstance(): void + { + $target = new NonStaticProviderTarget(); + $instance = new class($target) implements CaseInstance { + public function __construct(private readonly NonStaticProviderTarget $obj) {} + + #[\Override] + public function getInstance(): object { return $this->obj; } + + #[\Override] + public function hasInstance(): bool { return true; } + }; + + $dispatcher = self::createDispatcher(); + $interceptor = new DataProviderInterceptor($dispatcher); + $info = self::createTestInfoWithInstance($instance); + $callCount = 0; + $next = static function (TestInfo $info) use (&$callCount): TestResult { + ++$callCount; + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + // instanceProvider() returns [[10], [20]] — 2 data sets + Assert::same($callCount, 2); + Assert::same($result->status, Status::Passed); + } + + #[ExpectException(\LogicException::class)] + public function throwsWhenNonStaticProviderUsedWithoutClassInstance(): void + { + $dispatcher = self::createDispatcher(); + $interceptor = new DataProviderInterceptor($dispatcher); + + // CaseInfo with no instance — the null coalescing throw should fire. + $reflection = new \ReflectionMethod(NonStaticProviderTarget::class, 'target'); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); + $caseInfo = new CaseInfo(definition: $caseDefinition, instance: null); + $testDefinition = new TestDefinition(reflection: $reflection); + $info = new TestInfo(name: 'target', caseInfo: $caseInfo, testDefinition: $testDefinition); + + $interceptor->runTest($info, static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed)); + } + private static function createDispatcher(): EventDispatcherInterface { return new class() implements EventDispatcherInterface { @@ -80,4 +128,18 @@ private static function createTestInfo(): TestInfo testDefinition: $testDefinition, ); } + + private static function createTestInfoWithInstance(CaseInstance $instance): TestInfo + { + $reflection = new \ReflectionMethod(NonStaticProviderTarget::class, 'target'); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); + $caseInfo = new CaseInfo(definition: $caseDefinition, instance: $instance); + $testDefinition = new TestDefinition(reflection: $reflection); + + return new TestInfo( + name: 'target', + caseInfo: $caseInfo, + testDefinition: $testDefinition, + ); + } } 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 @@ + - */ - public array $suites; - - /** - * Class, method, or function names to filter by. - * - * Supports formats: - * - Method: ClassName::methodName or Namespace\ClassName::methodName - * - FQN: Namespace\ClassName or Namespace\functionName - * - Fragment: methodName, functionName, or ShortClassName - * - * @var list - */ - public array $names; - /** * Absolute file or directory paths to filter by. * @@ -45,44 +26,6 @@ */ public array $paths; - /** - * Test case types to include, e.g. 'test', 'inline', 'bench', etc. A case passes when its type - * is in this list (OR logic). An empty list means no type inclusion filter is applied. - * @see TestType - * - * @var list - */ - public array $type; - - /** - * Test case types to exclude. A case is dropped when its type is in this list. - * Exclusion takes precedence over inclusion. - * @see TestType - * - * @var list - */ - public array $notType; - - /** - * Group names to include. A test passes when its group set intersects this list (OR logic). - * An empty list means no group inclusion filter is applied. - * - * @see \Testo\Filter\Group - * - * @var list - */ - public array $groups; - - /** - * Group names to exclude. A test is dropped when its group set intersects this list. - * Exclusion takes precedence over inclusion. - * - * @see \Testo\Filter\Group - * - * @var list - */ - public array $excludeGroups; - /** * @param list $suites Test suite names to filter by * @param list $names Class, method, or function names to filter by @@ -93,21 +36,52 @@ * @param list $excludeGroups Group names to exclude (takes precedence) */ public function __construct( - array $suites = [], - array $names = [], + /** + * Test suite names to filter by. + * + * @var list + */ + public array $suites = [], + /** + * Class, method, or function names to filter by. + * + * Supports formats: + * - Method: ClassName::methodName or Namespace\ClassName::methodName + * - FQN: Namespace\ClassName or Namespace\functionName + * - Fragment: methodName, functionName, or ShortClassName + * + * @var list + */ + public array $names = [], array $paths = [], - array $type = [], - array $notType = [], - array $groups = [], - array $excludeGroups = [], + /** + * Test case types to include, e.g. 'test', 'inline', 'bench', etc. A case passes when its type + * is in this list (OR logic). An empty list means no type inclusion filter is applied. + * @see TestType + */ + public array $type = [], + /** + * Test case types to exclude. A case is dropped when its type is in this list. + * Exclusion takes precedence over inclusion. + * @see TestType + */ + public array $notType = [], + /** + * Group names to include. A test passes when its group set intersects this list (OR logic). + * An empty list means no group inclusion filter is applied. + * + * @see \Testo\Filter\Group + */ + public array $groups = [], + /** + * Group names to exclude. A test is dropped when its group set intersects this list. + * Exclusion takes precedence over inclusion. + * + * @see \Testo\Filter\Group + */ + public array $excludeGroups = [], ) { - $this->suites = $suites; - $this->names = $names; $this->paths = \array_map(static fn(string|Path $p): Path => Path::create($p)->absolute(), $paths); - $this->type = $type; - $this->notType = $notType; - $this->groups = $groups; - $this->excludeGroups = $excludeGroups; } /** diff --git a/plugin/filter/src/Internal/FilterInterceptor.php b/plugin/filter/src/Internal/FilterInterceptor.php index 991ab81b..33dd882a 100644 --- a/plugin/filter/src/Internal/FilterInterceptor.php +++ b/plugin/filter/src/Internal/FilterInterceptor.php @@ -232,7 +232,6 @@ public function locateTestCases(FileDefinitions $file, callable $next): CaseDefi * * Also records {@see DataPointer}s for matched tests so Stage 3 can inject them. * - * @param CaseDefinition $case * * @return array Matched tests keyed by name */ diff --git a/rector.php b/rector.php new file mode 100644 index 00000000..9101afa9 --- /dev/null +++ b/rector.php @@ -0,0 +1,34 @@ +withPaths([ + __DIR__ . '/core', + __DIR__ . '/plugin', + __DIR__ . '/bridge', + ]) + ->withSkip([ + __DIR__ . '/bridge/rector', + __DIR__ . '/bridge/symfony-console/resources/stubs', + __DIR__ . '/bin', + '*/tests/*', + '*/Stub/*', + '*/Fixture/*', + // Removing unused public-method parameters breaks implementing classes and callers. + RemoveUnusedPublicMethodParameterRector::class, + // RepeatInterceptor uses a closure with use (&$symbols) for batched symbol flushing; + // Rector's deadCode rules incorrectly remove the body as "unused". + __DIR__ . '/plugin/repeat/src/Internal/RepeatInterceptor.php', + // DeferredGenerator uses `return $result; yield;` to create a finished generator — + // a valid PHP trick that Rector converts to an invalid arrow function. + __DIR__ . '/plugin/data/src/Internal/DeferredGenerator.php', + ]) + ->withPhpSets(php82: true) + ->withPreparedSets( + deadCode: true, + typeDeclarations: true, + ); diff --git a/testo.php b/testo.php index 82374d61..56dcafbb 100644 --- a/testo.php +++ b/testo.php @@ -20,6 +20,7 @@ 'plugin/codecov/tests', 'plugin/convention/tests', 'plugin/data/tests', + 'plugin/error-handler/tests', 'plugin/facade/tests', 'plugin/filter/tests', 'plugin/inline/tests', @@ -56,6 +57,7 @@ require 'plugin/codecov/tests/suites.php', require 'plugin/convention/tests/suites.php', require 'plugin/data/tests/suites.php', + require 'plugin/error-handler/tests/suites.php', require 'plugin/facade/tests/suites.php', require 'plugin/filter/tests/suites.php', require 'plugin/inline/tests/suites.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 @@ +getContainer()->get(ApplicationConfig::class); + } finally { + \is_file($tmp) and \unlink($tmp); + } + } +} diff --git a/tests/Application/Unit/Messenger/StateTest.php b/tests/Application/Unit/Messenger/StateTest.php index d9d70da2..f5ffa650 100644 --- a/tests/Application/Unit/Messenger/StateTest.php +++ b/tests/Application/Unit/Messenger/StateTest.php @@ -10,6 +10,7 @@ use Testo\Codecov\Covers; use Testo\Core\Log\Level; use Testo\Core\Log\Message; +use Testo\Event\Message\MessageReceived; use Testo\Test; #[Test] @@ -144,6 +145,39 @@ public function commitSortsOutOfOrderMessagesByTime(): void Assert::same($this->contents($root), ['early', 'late']); } + public function heldEventsFromNestedHoldForkAreReleasedByOuterCommit(): void + { + /** @var list $dispatched */ + $dispatched = []; + $dispatcher = new class($dispatched) implements EventDispatcherInterface { + /** @param list $dispatched */ + public function __construct(private array &$dispatched) {} + + #[\Override] + public function dispatch(object $event): object + { + if ($event instanceof MessageReceived) { + $this->dispatched[] = $event->message->content; + } + return $event; + } + }; + + $root = new State($dispatcher); + $parent = $root->fork(holdEvents: true); + $child = $parent->fork(holdEvents: true); + + // Record out-of-order by time so the usort in absorbEvents makes a visible difference. + $child->record(self::message(2.0, 'late')); + $child->record(self::message(1.0, 'early')); + + $child->commit(); + Assert::same($dispatched, []); // still held by parent + + $parent->commit(); + Assert::same($dispatched, ['early', 'late']); // released in time order + } + public function destroyClearsBuffer(): void { $state = new State(self::dispatcher()); diff --git a/tests/Output/Unit/Terminal/FormatterTest.php b/tests/Output/Unit/Terminal/FormatterTest.php index e1866b98..d98cc0f3 100644 --- a/tests/Output/Unit/Terminal/FormatterTest.php +++ b/tests/Output/Unit/Terminal/FormatterTest.php @@ -7,7 +7,10 @@ use Testo\Assert; use Testo\Assert\State\Assertion\ComparisonFailure; use Testo\Core\Value\Status; +use Testo\Core\Value\Summary; +use Testo\Output\Terminal\Renderer\FormattedItem; use Testo\Output\Terminal\Renderer\Formatter; +use Testo\Output\Terminal\Renderer\OutputFormat; use Testo\Output\Terminal\Renderer\Style; use Testo\Test; @@ -103,6 +106,39 @@ public function emptyBannerReadsNoTests(): void Assert::string(Formatter::emptyBanner())->contains('NO TESTS'); } + public function summaryContainsStatusBreakdown(): void + { + $summary = new Summary( + counts: [Status::Passed->name => 2, Status::Failed->name => 1], + metrics: ['assertions' => 5], + duration: 0.5, + ); + + $output = Formatter::summary($summary, 1.0); + + Assert::string($output)->contains('Summary'); + Assert::string($output)->contains('2 passed'); + Assert::string($output)->contains('1 failed'); + } + + public function formatRunInCompactModeShowsItemName(): void + { + $item = new FormattedItem(name: 'myTest', status: Status::Passed); + + $output = Formatter::formatRun($item, OutputFormat::Compact); + + Assert::string($output)->contains('myTest'); + } + + public function formatRunInDotsModeReturnsPassedDot(): void + { + $item = new FormattedItem(name: 'myTest', status: Status::Passed); + + $dot = Formatter::formatRun($item, OutputFormat::Dots); + + Assert::same($dot, '.'); + } + protected function setUp(): void { // Strip ANSI styling so assertions match raw text regardless of TTY config.