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/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 78540ae9..8a56079f 100644 --- a/composer.json +++ b/composer.json @@ -177,6 +177,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 09976a56..4b1e0d45 100644 --- a/core/Application/Internal/Messenger/State.php +++ b/core/Application/Internal/Messenger/State.php @@ -143,7 +143,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; } @@ -176,7 +176,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/Core/Context/Identity.php b/core/Core/Context/Identity.php index 10acd42e..d0b39adc 100644 --- a/core/Core/Context/Identity.php +++ b/core/Core/Context/Identity.php @@ -43,6 +43,10 @@ public int $runtimeId; /** + * @param int<1, max>|null $parentId Run this one opens inside; the step-down factories pass it, and + * a suite has none. {@see $parentId} + */ + public function __construct(/** * {@see $runtimeId} of the run this one opened inside — the suite for a case, the case for a test, * the test for a data set — and `null` at a suite, which opens inside the run itself. * @@ -55,19 +59,10 @@ * {@see Identity\TestIdentity::$pipelineId}. The two still answer different questions — *whose * child is this* and *which test run is this part of* — and part company at every other level: * a test's parent is its case, while its `pipelineId` is itself. - * - * @var int<1, max>|null - */ - public ?int $parentId; - - /** - * @param int<1, max>|null $parentId Run this one opens inside; the step-down factories pass it, and - * a suite has none. {@see $parentId} */ - public function __construct(?int $parentId = null) - { + public ?int $parentId = null, + ) { $this->runtimeId = RuntimeSequence::next(); - $this->parentId = $parentId; } /** diff --git a/core/Core/Internal/RuntimeSequence.php b/core/Core/Internal/RuntimeSequence.php index cb28aefc..e69e957f 100644 --- a/core/Core/Internal/RuntimeSequence.php +++ b/core/Core/Internal/RuntimeSequence.php @@ -14,6 +14,7 @@ */ final class RuntimeSequence { + /** @var int<0, max> */ private static int $last = 0; /** @@ -21,7 +22,6 @@ final class RuntimeSequence */ public static function next(): int { - /** @var int<1, max> */ return ++self::$last; } } 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 6b74a174..caf06eba 100644 --- a/core/Output/Rendering/ChannelRenderer.php +++ b/core/Output/Rendering/ChannelRenderer.php @@ -97,16 +97,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/Rendering/SharedStream.php b/core/Output/Rendering/SharedStream.php index e1a46e33..0791e463 100644 --- a/core/Output/Rendering/SharedStream.php +++ b/core/Output/Rendering/SharedStream.php @@ -23,9 +23,6 @@ */ final class SharedStream { - /** @var resource */ - private $stream; - /** * Test currently writing live; `null` when the stream is free. */ @@ -56,10 +53,7 @@ final class SharedStream /** * @param resource $stream */ - public function __construct($stream) - { - $this->stream = $stream; - } + public function __construct(private $stream) {} /** * Write on behalf of `$owner`, or with no owner at all. diff --git a/core/Output/Teamcity/Teamcity/TeamcityLogger.php b/core/Output/Teamcity/Teamcity/TeamcityLogger.php index ba776fb7..f26f4ffc 100644 --- a/core/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/core/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -347,8 +347,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 5283c1b6..104ecbbf 100644 --- a/core/Output/Terminal/Renderer/TerminalLogger.php +++ b/core/Output/Terminal/Renderer/TerminalLogger.php @@ -498,7 +498,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 1b7a69b5..461f25ff 100644 --- a/core/Pipeline/Attribute/FallbackInterceptor.php +++ b/core/Pipeline/Attribute/FallbackInterceptor.php @@ -23,7 +23,7 @@ * @api */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] -final class FallbackInterceptor +final readonly class FallbackInterceptor { public function __construct( /** @@ -31,6 +31,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 18c90bb4..dadd66ba 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 @@ -67,13 +67,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..6f386a9a 100644 --- a/core/Pipeline/Pipeline.php +++ b/core/Pipeline/Pipeline.php @@ -28,7 +28,7 @@ final class Pipeline private mixed $last; /** @var list */ - private array $interceptors = []; + private array $interceptors; /** @var int<0, max> Current interceptor key */ private int $current = 0; @@ -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/core/Tokenizer/Reflection/TokenizedFile.php b/core/Tokenizer/Reflection/TokenizedFile.php index c95b9b5c..4380106d 100644 --- a/core/Tokenizer/Reflection/TokenizedFile.php +++ b/core/Tokenizer/Reflection/TokenizedFile.php @@ -76,7 +76,7 @@ final class TokenizedFile * * @internal */ - private int $countTokens = 0; + private readonly int $countTokens; /** * Namespaces used in file and their token positions. diff --git a/plugin/assert/src/Internal/Assertion/AssertJson.php b/plugin/assert/src/Internal/Assertion/AssertJson.php index e4512ab6..9c5edd6a 100644 --- a/plugin/assert/src/Internal/Assertion/AssertJson.php +++ b/plugin/assert/src/Internal/Assertion/AssertJson.php @@ -410,7 +410,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 8214cb1e..7c719a6f 100644 --- a/plugin/data/src/Internal/DataProviderInterceptor.php +++ b/plugin/data/src/Internal/DataProviderInterceptor.php @@ -222,8 +222,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', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Data/Unit'), 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 { @@ -82,4 +130,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', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Data/Unit'), definition: $caseDefinition, instance: $instance); + $testDefinition = new TestDefinition(reflection: $reflection); + + return new TestInfo( + name: 'target', + caseInfo: $caseInfo, + testDefinition: $testDefinition, + ); + } } diff --git a/plugin/fiber/src/Exception/CompositeException.php b/plugin/fiber/src/Exception/CompositeException.php index 8844e11b..370e66ec 100644 --- a/plugin/fiber/src/Exception/CompositeException.php +++ b/plugin/fiber/src/Exception/CompositeException.php @@ -19,20 +19,14 @@ final class CompositeException extends \RuntimeException { /** + * @param non-empty-array $errors + */ + public function __construct(/** * The collected throwables, keyed by whatever names each fiber to the producer: the task id for * scope/batch failures, or the argument key for {@see \Testo\Fiber\Coroutine::concurrently()}. - * - * @var non-empty-array */ - public readonly array $errors; - - /** - * @param non-empty-array $errors - */ - public function __construct(array $errors) + public readonly array $errors) { - $this->errors = $errors; - $lines = \array_map( static fn(int|string $key, \Throwable $e): string => \sprintf( ' %s %s: %s', @@ -40,17 +34,17 @@ public function __construct(array $errors) $e::class, $e->getMessage(), ), - \array_keys($errors), - \array_values($errors), + \array_keys($this->errors), + \array_values($this->errors), ); parent::__construct( \sprintf( "%d fiber(s) failed:\n%s", - \count($errors), + \count($this->errors), \implode("\n", $lines), ), - previous: $errors[\array_key_first($errors)], + previous: $this->errors[\array_key_first($this->errors)], ); } } diff --git a/plugin/fiber/src/Internal/RunInFiberInterceptor.php b/plugin/fiber/src/Internal/RunInFiberInterceptor.php index 0e1fb6a6..42715156 100644 --- a/plugin/fiber/src/Internal/RunInFiberInterceptor.php +++ b/plugin/fiber/src/Internal/RunInFiberInterceptor.php @@ -70,7 +70,6 @@ public function runTest(TestInfo $info, callable $next): TestResult $task->error === null or throw $task->error; - /** @var TestResult */ return $task->result; } } diff --git a/plugin/filter/Filter.php b/plugin/filter/Filter.php index b950dd8d..c74a8c4b 100644 --- a/plugin/filter/Filter.php +++ b/plugin/filter/Filter.php @@ -17,25 +17,6 @@ */ final readonly class Filter { - /** - * 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; - /** * 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/tests/Application/Stub/SuiteFactoryFixture/FixtureCase.php b/tests/Application/Stub/SuiteFactoryFixture/FixtureCase.php new file mode 100644 index 00000000..f474222e --- /dev/null +++ b/tests/Application/Stub/SuiteFactoryFixture/FixtureCase.php @@ -0,0 +1,18 @@ +getContainer()->get(ApplicationConfig::class); + } finally { + \is_file($tmp) and \unlink($tmp); + } + } +} diff --git a/tests/Application/Unit/Internal/SuiteFactoryTest.php b/tests/Application/Unit/Internal/SuiteFactoryTest.php new file mode 100644 index 00000000..28684f88 --- /dev/null +++ b/tests/Application/Unit/Internal/SuiteFactoryTest.php @@ -0,0 +1,75 @@ +create($config, new Filter()); + + Assert::same($info->name, 'SuiteFactoryFixture'); + $cases = $info->testCases->getCases(); + Assert::same(\count($cases), 1); + Assert::same($cases[0]->reflection?->getName(), 'Tests\Application\Stub\SuiteFactoryFixture\FixtureCase'); + } + + public function createReturnsEmptySuiteInfoWhenNoFilesMatch(): void + { + $factory = self::makeFactory(); + $config = new SuiteConfig( + name: 'EmptyFixture', + location: new FinderConfig(include: [__DIR__ . '/../../../Application/Stub/EmptyRun']), + ); + + $info = $factory->create($config, new Filter()); + + Assert::same($info->testCases->getCases(), []); + } + + private static function makeFactory(): SuiteFactory + { + $container = new ObjectContainer(); + $provider = new InterceptorProvider($container); + $provider->addInterceptor(new TestoAttributesLocatorInterceptor()); + + $reporter = new ErrorReporter(new MessengerHub(new SpyDispatcher())); + + return new SuiteFactory($provider, $reporter); + } + + private static function fixtureDir(): string + { + return __DIR__ . '/../../Stub/SuiteFactoryFixture'; + } +} 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/Core/Testing/Unit/Attribute/TestingSuiteTest.php b/tests/Core/Testing/Unit/Attribute/TestingSuiteTest.php new file mode 100644 index 00000000..4d2373dd --- /dev/null +++ b/tests/Core/Testing/Unit/Attribute/TestingSuiteTest.php @@ -0,0 +1,48 @@ + ['db']], + arguments: ['name' => 'value'], + env: ['FOO' => 'bar'], + ); + + Assert::same($attribute->path, 'some/stub/path'); + Assert::same($attribute->plugins, ['SomePlugin']); + Assert::same($attribute->options, ['group' => ['db']]); + Assert::same($attribute->arguments, ['name' => 'value']); + Assert::same($attribute->env, ['FOO' => 'bar']); + } + + public function constructorDefaultsAreAllEmptyArrays(): void + { + $attribute = new TestingSuite(path: 'some/stub/path'); + + Assert::same($attribute->plugins, []); + Assert::same($attribute->options, []); + Assert::same($attribute->arguments, []); + Assert::same($attribute->env, []); + } +} diff --git a/tests/Output/Unit/Rendering/ChannelRendererTest.php b/tests/Output/Unit/Rendering/ChannelRendererTest.php index 5da021c8..edb53549 100644 --- a/tests/Output/Unit/Rendering/ChannelRendererTest.php +++ b/tests/Output/Unit/Rendering/ChannelRendererTest.php @@ -116,6 +116,20 @@ public function formatTimeClampsMillisecondsToNineNineNine(): void ); } + public function formatTimeFallsBackToManualCalculationWhenDateTimeParsingFails(): void + { + $renderer = new ChannelRenderer(); + + // INF formats as the literal string "INF", which DateTimeImmutable::createFromFormat('U.u', ...) + // cannot parse ("U" expects digits) -> exercises the manual-arithmetic fallback branch. + $out = self::stripAnsi($renderer->render(self::message('sql', 'q', \INF))); + + Assert::true( + \preg_match('/^\[sql] \d{2}:\d{2}:\d{2}\.\d{3}\n/', $out) === 1, + "Fallback path did not produce a HH:MM:SS.mmm header: {$out}", + ); + } + public function aChannelAlwaysGetsTheSameHeaderColor(): void { // Derived from the name, so it survives across renderers — that is what lets a reader track one diff --git a/tests/Output/Unit/Terminal/FormatterTest.php b/tests/Output/Unit/Terminal/FormatterTest.php index 9e360398..d98cc0f3 100644 --- a/tests/Output/Unit/Terminal/FormatterTest.php +++ b/tests/Output/Unit/Terminal/FormatterTest.php @@ -7,7 +7,11 @@ 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; #[Test] @@ -102,6 +106,50 @@ 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. + Style::setColorsEnabled(false); + } + + protected function tearDown(): void + { + Style::setColorsEnabled(true); + } + /** * Count diff body lines that begin with the given marker (`-` or `+`). * Header lines (`--- Expected`, `+++ Actual`) are skipped.