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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions core/Output/Rendering/ChannelRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,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
Loading