Skip to content
12 changes: 6 additions & 6 deletions core/Output/Rendering/ChannelRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ private static function header(string $channel, float $time): string

/**
* Formats a {@see \microtime()} timestamp as `HH:MM:SS.mmm` wall-clock time.
*
* @return non-empty-string
*/
private static function formatTime(float $time): string
{
$seconds = (int) $time;
$millis = \min(999, (int) \round(($time - (float) $seconds) * 1000.0));
$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;

/** @var non-empty-string */
return \date('H:i:s', $seconds) . \sprintf('.%03d', $millis);
return \sprintf('%02d:%02d:%02d.%03d', $h, $m, $s, $millis);
}
}
2 changes: 0 additions & 2 deletions core/Output/Terminal/Renderer/Style.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
26 changes: 26 additions & 0 deletions core/Testing/Attribute/ExpectAssertionsCount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Testo\Testing\Attribute;

/**
* Asserts that the {@see \Testo\Core\Context\TestResult} returned by
* {@see \Testo\Testing\Helper\TestRunner::runTest()} recorded exactly the given number of
* assertions (the `assertions` metric contributed by the Assert plugin).
*
* The test method must return the `TestResult` directly. If the count does not match, the
* outer test is marked {@see \Testo\Core\Value\Status::Failed}.
*
* @see ExpectTestStatus
* @see ExpectTestResultAttribute
* @api
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final readonly class ExpectAssertionsCount
{
/**
* @param int<0, max> $count Expected number of assertions.
*/
public function __construct(public int $count) {}
}
26 changes: 26 additions & 0 deletions core/Testing/Attribute/ExpectTestResultAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Testo\Testing\Attribute;

/**
* Asserts that the {@see \Testo\Core\Context\TestResult} returned by
* {@see \Testo\Testing\Helper\TestRunner::runTest()} contains an attribute stored under
* the given key. Repeatable — apply multiple times to check for several attributes.
*
* The test method must return the `TestResult` directly. If the attribute is absent, the
* outer test is marked {@see \Testo\Core\Value\Status::Failed}.
*
* @see ExpectTestStatus
* @see ExpectAssertionsCount
* @api
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
final readonly class ExpectTestResultAttribute
{
/**
* @param non-empty-string $name Attribute key to look up, typically a class-string.
*/
public function __construct(public string $name) {}
}
24 changes: 24 additions & 0 deletions core/Testing/Attribute/ExpectTestStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Testo\Testing\Attribute;

use Testo\Core\Value\Status;

/**
* Asserts that the {@see \Testo\Core\Context\TestResult} returned by
* {@see \Testo\Testing\Helper\TestRunner::runTest()} has the expected {@see Status}.
*
* The test method must return the `TestResult` directly. If the expected status does not
* match the stub's actual status, the outer test is marked {@see Status::Failed}.
*
* @see ExpectAssertionsCount
* @see ExpectTestResultAttribute
* @api
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final readonly class ExpectTestStatus
{
public function __construct(public Status $status) {}
}
2 changes: 2 additions & 0 deletions core/Testing/InjectPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Testo\Common\PluginConfigurator;
use Testo\Pipeline\InterceptorCollector;
use Testo\Testing\Attribute\Inject;
use Testo\Testing\Internal\ExpectInterceptor;
use Testo\Testing\Internal\InjectInterceptor;

/**
Expand All @@ -27,5 +28,6 @@ public function configure(Container $container): void
# Registered as a class-string so the collector resolves it through the
# container and autowires the {@see Container} dependency.
$container->get(InterceptorCollector::class)->addInterceptor(InjectInterceptor::class);
$container->get(InterceptorCollector::class)->addInterceptor(new ExpectInterceptor());
}
}
98 changes: 98 additions & 0 deletions core/Testing/Internal/ExpectInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Testo\Testing\Internal;

use Testo\Common\Reflection;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Core\Value\Status;
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Pipeline\Middleware\TestRunInterceptor;
use Testo\Testing\Attribute\ExpectAssertionsCount;
use Testo\Testing\Attribute\ExpectTestResultAttribute;
use Testo\Testing\Attribute\ExpectTestStatus;

/**
* Validates {@see ExpectTestStatus}, {@see ExpectAssertionsCount}, and
* {@see ExpectTestResultAttribute} declarations against the {@see TestResult} returned by
* the test method (typically from {@see \Testo\Testing\Helper\TestRunner::runTest()}).
*
* Passes through immediately when none of the expect attributes are present on the method.
* If the outer test already failed before returning a result, the failure is preserved
* without additional validation so the original error is not obscured.
*
* @internal
* @psalm-internal Testo
*/
#[InterceptorOptions(order: InterceptorOptions::ORDER_CLOSE_TO_TEST - 1)]
final readonly class ExpectInterceptor implements TestRunInterceptor
{
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
$reflection = $info->testDefinition->reflection;

$statusAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectTestStatus::class);
$countAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectAssertionsCount::class);
$attrAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectTestResultAttribute::class);

if ($statusAttrs === [] && $countAttrs === [] && $attrAttrs === []) {
return $next($info);
}

$outerResult = $next($info);

// Pre-existing failure or non-terminal status — preserve as-is so the original error is
// not obscured by a misleading "expected TestResult" message.
if (!$outerResult->status->isCompleted() || $outerResult->status->isFailure()) {
return $outerResult;
}

$stubResult = $outerResult->result;
if (!$stubResult instanceof TestResult) {
return $outerResult
->with(status: Status::Failed)
->withFailure(new \LogicException(
'Test must return the TestResult from TestRunner::runTest() when using Expect* attributes, got '
. \get_debug_type($stubResult),
));
}

$failures = [];

if ($statusAttrs !== []) {
/** @var ExpectTestStatus $expect */
$expect = $statusAttrs[0]->newInstance();
if ($stubResult->status !== $expect->status) {
$failures[] = "Expected stub status {$expect->status->name}, got {$stubResult->status->name}";
}
}

if ($countAttrs !== []) {
/** @var ExpectAssertionsCount $expect */
$expect = $countAttrs[0]->newInstance();
$actual = $stubResult->summary->metric('assertions');
if ($actual !== $expect->count) {
$failures[] = "Expected {$expect->count} assertion(s), got {$actual}";
}
}

foreach ($attrAttrs as $attr) {
/** @var ExpectTestResultAttribute $expect */
$expect = $attr->newInstance();
if ($stubResult->getAttribute($expect->name) === null) {
$failures[] = "Expected TestResult attribute '{$expect->name}' to be present";
}
}

if ($failures === []) {
return $outerResult;
}

return $outerResult
->with(status: Status::Failed)
->withFailure(new \RuntimeException(\implode("\n", $failures)));
}
}
10 changes: 10 additions & 0 deletions tests/Application/Stub/EmptyRun/.placeholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

// Intentionally empty — fixture directory for EmptyRunTest.
// This file exists solely so the PHPUnit mirror build (bin/build-phpunit.php)
// copies it to tests/PhpUnit/Application/Stub/EmptyRun/, creating the directory
// that the mirrored EmptyRunTest references via __DIR__ . '/../../Stub/EmptyRun'.
// The file contains no classes or tests; the Testo application run in the test
// finds zero tests here and correctly reports Status::Risky.
Loading