diff --git a/core/Application/Internal/Messenger/State.php b/core/Application/Internal/Messenger/State.php index 86f00130..09976a56 100644 --- a/core/Application/Internal/Messenger/State.php +++ b/core/Application/Internal/Messenger/State.php @@ -6,6 +6,7 @@ use Internal\Destroy\Destroyable; use Psr\EventDispatcher\EventDispatcherInterface; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Log\Message; use Testo\Event\Message\MessageReceived; @@ -40,10 +41,15 @@ final class State implements Destroyable private bool $suspended = false; + /** + * @param TestIdentity|null $identity Test whose {@see MessageReceived} events this state stamps; + * `null` when the state belongs to no test. Read by the hub so a nested scope can inherit it. + */ public function __construct( private readonly EventDispatcherInterface $dispatcher, private readonly ?self $parent = null, private readonly bool $holdEvents = false, + public readonly ?TestIdentity $identity = null, ) {} /** @@ -77,7 +83,8 @@ public function getMessages(): array public function fork(bool $holdEvents = false): self { - return new self($this->dispatcher, $this, $holdEvents); + # A fork belongs to the same test as its parent, so it inherits the parent's identity. + return new self($this->dispatcher, $this, $holdEvents, $this->identity); } #[\Override] @@ -114,7 +121,7 @@ private function dispatch(Message $message): void { $this->suspended = true; try { - $this->dispatcher->dispatch(new MessageReceived($message)); + $this->dispatcher->dispatch(new MessageReceived($message, $this->identity)); } finally { $this->suspended = false; } diff --git a/core/Application/Internal/MessengerHub.php b/core/Application/Internal/MessengerHub.php index a752e402..854ae297 100644 --- a/core/Application/Internal/MessengerHub.php +++ b/core/Application/Internal/MessengerHub.php @@ -9,6 +9,7 @@ use Testo\Application\Internal\Messenger\MutableContainer; use Testo\Common\Messenger; use Testo\Common\Messenger\Channel; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; @@ -48,10 +49,14 @@ public function channel(string $name): Channel } #[\Override] - public function scope(\Closure $scope): mixed + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed { $old = $this->state->state; - $new = new State($this->eventDispatcher); + // A test scope carries the test's identity, so every MessageReceived dispatched from within it + // is stamped with that test — the seam that keeps interleaving tests' output attributable. + // With no identity given the ambient one carries over, like in fork(): a nested scope opened + // mid-test still belongs to that test, and stamping null would silently strip attribution. + $new = new State($this->eventDispatcher, identity: $identity ?? $old->identity); try { $this->state->state = $new; if (\Fiber::getCurrent() === null) { diff --git a/core/Application/Internal/Runner/SuiteRunner.php b/core/Application/Internal/Runner/SuiteRunner.php index 3dc40dab..8bdc2132 100644 --- a/core/Application/Internal/Runner/SuiteRunner.php +++ b/core/Application/Internal/Runner/SuiteRunner.php @@ -87,6 +87,7 @@ public function run(SuiteInfo $suite, Filter $filter): SuiteResult try { $caseInfo = new CaseInfo( definition: $caseDefinition, + suiteIdentity: $suite->identity, instance: $caseDefinition->reflection === null ? null : new SimpleCaseInstantiator($caseDefinition->reflection), diff --git a/core/Common/Messenger.php b/core/Common/Messenger.php index 58cd240c..aa745317 100644 --- a/core/Common/Messenger.php +++ b/core/Common/Messenger.php @@ -4,6 +4,7 @@ namespace Testo\Common; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; @@ -80,11 +81,19 @@ public function channel(string $name): Channel; * inside the closure observes only what was written within it. Fiber-aware: the parent state * is restored across suspension points. * + * Every {@see MessageReceived} dispatched from within the scope is stamped with `$identity`, so a + * consumer can attribute the message to that test even while other tests interleave their output + * on the same stream. When `$identity` is omitted the enclosing scope's identity carries over, + * like in {@see fork()}: a scope opened mid-test still belongs to that test. The framework sets + * the identity once per test (in its per-test output scope) — a plugin rarely needs to pass one. + * * @template T * @param \Closure(self): T $scope + * @param TestIdentity|null $identity Test this scope's messages belong to; inherited from the + * enclosing scope when omitted. * @return T */ - public function scope(\Closure $scope): mixed; + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed; /** * Run the given closure within a fork: a mergeable child branch of the active scope. diff --git a/core/Core/Context/CaseInfo.php b/core/Core/Context/CaseInfo.php index 0dc74bbf..a885a482 100644 --- a/core/Core/Context/CaseInfo.php +++ b/core/Core/Context/CaseInfo.php @@ -4,6 +4,8 @@ namespace Testo\Core\Context; +use Testo\Core\Context\Identity\CaseIdentity; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Internal\Attributed; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Internal\DefaultTestHandler; @@ -19,6 +21,7 @@ use Attributed; public string $name; + public CaseIdentity $identity; /** * Handler for executing the test method. @@ -28,6 +31,8 @@ public \Closure $handler; /** + * @param SuiteIdentity $suiteIdentity Suite this case belongs to — the root its address descends from. Every + * case is reached through a suite, so there is no sensible stand-in to default to. * @param ?CaseInstance $instance Test Case class instance if class is defined, null otherwise. * @param array $attributes * @param callable(TestInfo): mixed $handler Invoker for the test method. @@ -36,6 +41,7 @@ */ public function __construct( public CaseDefinition $definition, + SuiteIdentity $suiteIdentity, public ?CaseInstance $instance = null, public array $attributes = [], callable $handler = new DefaultTestHandler(), @@ -43,18 +49,16 @@ public function __construct( ) { $this->name = $definition->getName(); $this->handler = $handler(...); + $this->identity = $suiteIdentity + ->toCase($definition->reflection?->getName(), $definition->type, $definition->file); } public function with( ?\Closure $handler = null, ): self { - return new self( - definition: $this->definition, - instance: $this->instance, - attributes: $this->attributes, - handler: $handler ?? $this->handler, - batchRunner: $this->batchRunner, - ); + # Clone rather than rebuild: re-running the constructor would mint a second address for a case + # that is still the same one, and the tests already descended from the first. + return $this->cloneWith('handler', $handler ?? $this->handler); } /** diff --git a/core/Core/Context/Identity.php b/core/Core/Context/Identity.php new file mode 100644 index 00000000..10acd42e --- /dev/null +++ b/core/Core/Context/Identity.php @@ -0,0 +1,88 @@ + + */ + public int $runtimeId; + + /** + * {@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. + * + * The one thing an address says about the level above it, and a number rather than a reference: + * reading it stays a field access, and a consumer building a tree of the run (an IDE's + * `parentNodeId`, say) reads the same field at every level instead of knowing which type it holds + * and where that type keeps its parent. Process-local, like the number it points at. + * + * A data set's parent is its test, so there it holds the same number as + * {@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) + { + $this->runtimeId = RuntimeSequence::next(); + $this->parentId = $parentId; + } + + /** + * The address in the form the outside world writes it: what `--filter` accepts and what TeamCity + * puts in a `locationHint`. + * + * This names *code*, so it carries neither the suite nor the type — the same class runs under any + * suite, and both are filtered separately (`--suite`, `--type`). The result is meant to be parsed, + * not read. + * + * Null where the level names no code at all: a case of free functions has no class to qualify, + * and nothing else about it belongs in an FQN. The levels that always have one narrow the return + * type back to `string`. + * + * @return non-empty-string|null + */ + abstract public function fqn(): ?string; +} diff --git a/core/Core/Context/Identity/CaseIdentity.php b/core/Core/Context/Identity/CaseIdentity.php new file mode 100644 index 00000000..7681c03d --- /dev/null +++ b/core/Core/Context/Identity/CaseIdentity.php @@ -0,0 +1,62 @@ +|null $parentId Run of the suite this case opened inside; passed by + * {@see SuiteIdentity::toCase()}. {@see Identity::$parentId} + */ + public function __construct( + public string $suite, + public ?string $case, + public string $type, + public Path $file, + ?int $parentId = null, + ) { + parent::__construct($parentId); + } + + /** + * Step down to a test of this case. + * + * @param non-empty-string $testName Name relative to this case — a bare method name when the case + * has a class, the function's own FQN when it does not. {@see TestIdentity::$test}. + */ + public function toTest(string $testName): TestIdentity + { + return new TestIdentity( + $this->suite, + $this->case, + $this->type, + $this->file, + $testName, + parentId: $this->runtimeId, + ); + } + + #[\Override] + public function fqn(): ?string + { + return $this->case; + } +} diff --git a/core/Core/Context/Identity/SuiteIdentity.php b/core/Core/Context/Identity/SuiteIdentity.php new file mode 100644 index 00000000..cfd2f008 --- /dev/null +++ b/core/Core/Context/Identity/SuiteIdentity.php @@ -0,0 +1,50 @@ +suite, $caseName, $type, $file, parentId: $this->runtimeId); + } + + /** + * A suite is a configuration entry, not code, so there is no narrower form to give: its name is + * both what it is called and what `--suite` matches. + */ + #[\Override] + public function fqn(): string + { + return $this->suite; + } +} diff --git a/core/Core/Context/Identity/TestIdentity.php b/core/Core/Context/Identity/TestIdentity.php new file mode 100644 index 00000000..8ded3f74 --- /dev/null +++ b/core/Core/Context/Identity/TestIdentity.php @@ -0,0 +1,161 @@ + …` twice is legal), so only the index tells two data + * sets apart. The key stays a label, and lives on the events that carry it. + * + * @api + */ +final readonly class TestIdentity extends Identity +{ + /** + * Run of the test this address belongs to: a test's own {@see Identity::$runtimeId}, and the + * batch's for every data set derived from it with {@see toDataSet()}. + * + * What consumers that need a whole test to stay together key on — one terminal block, one channel + * grouping, one TeamCity flow. {@see $runtimeId} cannot serve for that: it counts *this* run, so + * every data set has its own, and grouping by it would break a batch into as many blocks as it has + * data sets. Process-local like the number it is taken from. + * + * @var int<1, max> + */ + public int $pipelineId; + + /** + * The code this address names, without the data. {@see qualifiedName()} + * + * @var non-empty-string + */ + private string $qualifiedName; + + /** + * Machine-facing form of this address, composed once. {@see fqn()} + * + * @var non-empty-string + */ + private string $fqn; + + /** + * @param non-empty-string $suite Suite name as configured in `testo.php`. + * @param non-empty-string|null $case Class FQN, or null for a free function. + * {@see CaseIdentity::$case}. + * @param non-empty-string $type Case type — `test`, `inline`, `bench`, … + * {@see \Testo\Core\Value\TestType}. + * @param Path $file Path of the file the case was read from. + * {@see CaseIdentity::$file}. + * @param non-empty-string $test Name of the test **relative to `$case`** — a bare method name when + * there is a class, and the function's own FQN when there is not. A free function has no + * class to be relative to, so it carries its namespace here rather than in a field of its + * own; that also makes "a class *and* a namespace" a state this type cannot be put in. + * @param int<0, max>|null $dataProvider Index of the data provider a data set came from. Always the + * real index — unlike the display-facing one on {@see \Testo\Event\Test\TestDataSetStarting}, + * which is `null` when the test has a single provider. + * @param int<0, max>|null $dataSet Index of the data set within that provider. + * @param int<1, max>|null $pipelineId Test run this address is a part of; omit to open a new one. + * Only {@see toDataSet()} passes it, to keep a data set inside its batch's run — see + * {@see $pipelineId}. + * @param int<1, max>|null $parentId Run this one opened inside — the case for a test, the test for + * a data set. Passed by {@see CaseIdentity::toTest()} and {@see toDataSet()}. + * {@see Identity::$parentId} + */ + public function __construct( + public string $suite, + public ?string $case, + public string $type, + public Path $file, + public string $test, + public ?int $dataProvider = null, + public ?int $dataSet = null, + ?int $pipelineId = null, + ?int $parentId = null, + ) { + self::assertDataSetIsWholeOrAbsent($dataProvider, $dataSet); + + # The one place these strings are composed. `toDataSet()` rebuilds through here rather than + # copying, so a derived address can never carry a rendering of the coordinates it no longer has. + $coordinates = $dataProvider === null ? '' : ":{$dataProvider}:{$dataSet}"; + + $this->qualifiedName = $case === null ? $test : "{$case}::{$test}"; + $this->fqn = $this->qualifiedName . $coordinates; + + parent::__construct($parentId); + + # A test opens the run its data sets then join. + $this->pipelineId = $pipelineId ?? $this->runtimeId; + } + + /** + * A data set of this test: a more specific address, inside the same test run. + * + * Rebuilt rather than copied, so the constructor recomposes the strings for the new coordinates — + * a derived address can never carry a rendering of coordinates it no longer has. Its run is its + * own ({@see $runtimeId}) because a data set is a test in its own right, while {@see $pipelineId} + * carries over, which is what keeps a batch and its data sets in one report block and one + * TeamCity flow. + * + * The test run is also what the data set hangs under ({@see Identity::$parentId}) — so re-deriving + * one data set into another keeps them siblings under the test rather than nesting them. + * + * @param int<0, max>|null $dataProvider Index of the data provider; keeps the current one when omitted. + * @param int<0, max>|null $dataSet Index of the data set within it; keeps the current one when omitted. + */ + public function toDataSet(?int $dataProvider = null, ?int $dataSet = null): self + { + return new self( + $this->suite, + $this->case, + $this->type, + $this->file, + $this->test, + $dataProvider ?? $this->dataProvider, + $dataSet ?? $this->dataSet, + pipelineId: $this->pipelineId, + parentId: $this->pipelineId, + ); + } + + /** + * `Namespace\CaseClass::testMethod` — or `Namespace\testFunction` for a free function — plus + * `:dataProvider:dataSet` when this address points at a data set. The exact string `--filter` + * takes back, and the tail of TeamCity's `locationHint`, which prefixes `php_qn://` and + * {@see $file} and leads the qualified name with `\`. + */ + #[\Override] + public function fqn(): string + { + return $this->fqn; + } + + /** + * The code this address names, without the data: `Namespace\CaseClass::testMethod`, or + * `Namespace\testFunction` for a free function. + * + * Every data set of one test answers the same string — which is what consumers that group by test + * method need. Coverage entries and the JUnit `classname` are keyed on it, and Infection joins them + * by that name, so per-data-set granularity there would break the lookup rather than sharpen it. + * + * @return non-empty-string + */ + public function qualifiedName(): string + { + return $this->qualifiedName; + } + + private static function assertDataSetIsWholeOrAbsent(?int $dataProvider, ?int $dataSet): void + { + ($dataProvider === null) === ($dataSet === null) or throw new \InvalidArgumentException( + 'A data set address needs both a provider index and a data set index, or neither.', + ); + } +} diff --git a/core/Core/Context/SuiteInfo.php b/core/Core/Context/SuiteInfo.php index 71e2b8ed..974b4575 100644 --- a/core/Core/Context/SuiteInfo.php +++ b/core/Core/Context/SuiteInfo.php @@ -4,6 +4,7 @@ namespace Testo\Core\Context; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Definition\CaseDefinitions; use Testo\Core\Internal\Attributed; @@ -14,6 +15,11 @@ { use Attributed; + /** + * Address of this suite — the root every case and test of it descends from. + */ + public SuiteIdentity $identity; + /** * @param non-empty-string $name * @param array $attributes @@ -22,5 +28,7 @@ public function __construct( public string $name, public CaseDefinitions $testCases, public array $attributes = [], - ) {} + ) { + $this->identity = new SuiteIdentity($name); + } } diff --git a/core/Core/Context/TestInfo.php b/core/Core/Context/TestInfo.php index 96edb187..456036e2 100644 --- a/core/Core/Context/TestInfo.php +++ b/core/Core/Context/TestInfo.php @@ -4,6 +4,7 @@ namespace Testo\Core\Context; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Definition\TestDefinition; use Testo\Core\Internal\Attributed; @@ -16,10 +17,19 @@ { use Attributed; + /** + * Address of this test — or of the data set of it that is running. Tells tests apart when their + * events and output interleave, and names them the same way from one run to the next. + */ + public TestIdentity $identity; + /** * @param non-empty-string $name * @param array $arguments Arguments to pass to the test method. * @param array $attributes + * @param TestIdentity|null $identity Address to carry; derived from the case when omitted. Pass one + * to keep an address across a derived info — {@see with()} does, and a data-set address is + * made with {@see TestIdentity::toDataSet()}. */ public function __construct( public string $name, @@ -27,10 +37,20 @@ public function __construct( public TestDefinition $testDefinition, public array $arguments = [], public array $attributes = [], - ) {} + ?TestIdentity $identity = null, + ) { + $this->identity = $identity ?? $caseInfo->identity->toTest( + self::addressableName($caseInfo, $name, $testDefinition), + ); + } + /** + * @param TestIdentity|null $identity Address for the derived info; keeps this one's when omitted. + * A data-set runner passes {@see TestIdentity::toDataSet()} here. + */ public function with( ?array $arguments = null, + ?TestIdentity $identity = null, ): self { return new self( name: $this->name, @@ -38,6 +58,28 @@ public function with( testDefinition: $this->testDefinition, arguments: $arguments ?? $this->arguments, attributes: $this->attributes, + identity: $identity ?? $this->identity, ); } + + /** + * The name the address wants, which is not always {@see $name} — that one is for reading. + * + * A method is named relative to its class, so the bare name is already complete. A free function has + * no class to be relative to, so it has to carry its own namespace or the address would name a + * different function in every namespace that happens to define one by that name. + * + * @param non-empty-string $name + * @return non-empty-string + */ + private static function addressableName(CaseInfo $caseInfo, string $name, TestDefinition $definition): string + { + if ($caseInfo->identity->case !== null) { + return $name; + } + + $fqn = $definition->reflection->getName(); + + return $fqn === '' ? $name : $fqn; + } } diff --git a/core/Core/Definition/CaseDefinition.php b/core/Core/Definition/CaseDefinition.php index 5db3ab51..6813f6e3 100644 --- a/core/Core/Definition/CaseDefinition.php +++ b/core/Core/Definition/CaseDefinition.php @@ -4,6 +4,7 @@ namespace Testo\Core\Definition; +use Internal\Path; use Testo\Core\Context\TestInfo; use Testo\Core\Value\TestType; @@ -20,6 +21,7 @@ public function __construct( * @see TestType */ public string $type, + public Path $file, public ?\ReflectionClass $reflection = null, public TestDefinitions $tests = new TestDefinitions(), @@ -37,6 +39,7 @@ public function with( return new self( $name ?? $this->name, $this->type, + $this->file, $this->reflection, $tests ?? $this->tests, $handler ?? $this->handler, diff --git a/core/Core/Definition/CaseDefinitions.php b/core/Core/Definition/CaseDefinitions.php index 7d252e51..d664c88e 100644 --- a/core/Core/Definition/CaseDefinitions.php +++ b/core/Core/Definition/CaseDefinitions.php @@ -48,6 +48,7 @@ public function define( return $this->cases[$type][] = new CaseDefinition( name: $reflection?->getShortName() ?? $file->tokenizedFile->path->name(), type: $type, + file: $file->tokenizedFile->path, reflection: $reflection, handler: $handler, ); diff --git a/core/Core/Internal/RuntimeSequence.php b/core/Core/Internal/RuntimeSequence.php new file mode 100644 index 00000000..cb28aefc --- /dev/null +++ b/core/Core/Internal/RuntimeSequence.php @@ -0,0 +1,27 @@ + + */ + public static function next(): int + { + /** @var int<1, max> */ + return ++self::$last; + } +} diff --git a/core/Event/Message/MessageReceived.php b/core/Event/Message/MessageReceived.php index 1d885b4b..d4fcf9d0 100644 --- a/core/Event/Message/MessageReceived.php +++ b/core/Event/Message/MessageReceived.php @@ -4,6 +4,7 @@ namespace Testo\Event\Message; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Log\Message; /** @@ -18,7 +19,13 @@ */ final readonly class MessageReceived { + /** + * @param TestIdentity|null $identity Identity of the test the message was recorded for, or `null` + * when it belongs to no test (suite/case setup, output between tests). Lets consumers + * attribute interleaved output to the right test instead of guessing from ordering. + */ public function __construct( public Message $message, + public ?TestIdentity $identity = null, ) {} } diff --git a/core/Output/JUnit/Internal/JUnitWriter.php b/core/Output/JUnit/Internal/JUnitWriter.php index 0b94f2f9..53ad1e77 100644 --- a/core/Output/JUnit/Internal/JUnitWriter.php +++ b/core/Output/JUnit/Internal/JUnitWriter.php @@ -255,29 +255,15 @@ private static function rollupSuite(JUnitSuiteNode $suite): array */ private static function classnameFor(TestInfo $info): string { - $reflection = $info->testDefinition->reflection; - - // Class-bound tests: use the concrete (runtime) case class, not the - // method's declaring class. For a `#[Test]` inherited from an abstract - // base, getDeclaringClass() names the base — but the enclosing - // is named after the concrete subclass (see JUnitPlugin), - // and Infection joins coverage to a test file by matching this classname - // against that suite name. Diverging the two breaks the lookup. - $caseReflection = $info->caseInfo->definition->reflection; - if ($caseReflection !== null) { - return $caseReflection->getName(); - } - - if ($reflection instanceof \ReflectionMethod) { - return $reflection->getDeclaringClass()->getName(); - } - - // Free-function test: use the function's FQN, matching the per-function - // synthetic opened by JUnitPlugin. Keeps the - // testcase's classname aligned with the wrapping suite so Infection - // and CI tools can match the two unambiguously. - $name = $reflection->getName(); - return $name === '' ? '' : $name; + // Class-bound tests: the address already names the concrete (runtime) case class rather than + // the method's declaring class. For a `#[Test]` inherited from an abstract base, + // getDeclaringClass() would name the base — but the enclosing is named after the + // concrete subclass (see JUnitPlugin), and Infection joins coverage to a test file by matching + // this classname against that suite name. Diverging the two breaks the lookup. + // + // Free-function test: no class to name, so the function's own FQN stands in, matching the + // per-function synthetic opened by JUnitPlugin. + return $info->identity->case ?? $info->identity->qualifiedName(); } private static function outcomeFor(TestResult $result): ?JUnitCaseOutcome diff --git a/core/Output/JUnit/JUnitPlugin.php b/core/Output/JUnit/JUnitPlugin.php index 26003414..188eed9e 100644 --- a/core/Output/JUnit/JUnitPlugin.php +++ b/core/Output/JUnit/JUnitPlugin.php @@ -77,11 +77,12 @@ final class JUnitPlugin implements PluginConfigurator { /** - * Tracks whether we're inside a DataProvider batch, keyed by test - * definition object hash. Same guard `TeamcityPlugin` uses to avoid - * emitting both the per-dataset and the rolled-up ``. + * Tracks whether we're inside a DataProvider batch, keyed by + * {@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId}. Same guard `TeamcityPlugin` + * uses to avoid emitting both the per-dataset and the rolled-up ``; + * keyed per running test, so concurrently running tests are tracked independently. * - * @var array + * @var array */ private array $isBatch = []; @@ -185,14 +186,6 @@ public function configure(Container $container): void $listeners->addListener(TestPipelineFinished::class, $this->onTestPipelineFinished(...)); } - /** - * @return non-empty-string - */ - private static function getId(TestInfo $testInfo): string - { - return \spl_object_hash($testInfo->testDefinition); - } - private static function formatDatasetSuffix(string|int $datasetKey, ?int $providerIndex): string { return $providerIndex === null @@ -288,8 +281,7 @@ private function onTestBatchStarting(TestBatchStarting $event): void // Closed in onTestBatchFinished. $caseInfo->definition->reflection === null and $this->openFunctionSuite($event->testInfo); - $id = self::getId($event->testInfo); - $this->isBatch[$id] = true; + $this->isBatch[$event->testInfo->identity->pipelineId] = true; } private function onTestBatchFinished(TestBatchFinished $event): void @@ -331,7 +323,7 @@ private function onTestPipelineFinished(TestPipelineFinished $event): void return; } - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->pipelineId; if (isset($this->isBatch[$id])) { // DataProvider/multi-inline test — individual datasets were already emitted. unset($this->isBatch[$id]); diff --git a/core/Output/Rendering/ChannelRenderer.php b/core/Output/Rendering/ChannelRenderer.php index 23007ecc..6b74a174 100644 --- a/core/Output/Rendering/ChannelRenderer.php +++ b/core/Output/Rendering/ChannelRenderer.php @@ -12,19 +12,22 @@ * * Keeps track of the active channel and prints a colored channel header — the channel name in a * stable, name-derived color, followed by the (dimmed) time of the channel's first message — only - * when the channel changes. Consecutive messages from the same channel are appended verbatim, with + * when the channel changes. Consecutive messages of the same channel are appended verbatim, with * no header and no inserted line breaks. The header is kept on its own line. * - * Stateful: one instance per output stream; call {@see reset()} at each test boundary so every test - * starts its channel grouping afresh. Callers must skip empty content (the returned string is only - * guaranteed non-empty when the given content is). + * Stateful, and scoped to one block of output rather than to the stream: interleaved tests each get + * their own instance, so a block always opens with a fresh header and never inherits another test's + * channel. Call {@see reset()} at a boundary inside a block — data sets share their batch's — so the + * next message opens a header of its own. Callers must skip empty content (the returned string is + * only guaranteed non-empty when the given content is). * * @internal */ final class ChannelRenderer { /** - * Channel of the last rendered message; `null` before the first message / after a reset. + * Channel of the last rendered message, i.e. whose header is currently open; `null` before the + * first message / after a reset. * * @var non-empty-string|null */ @@ -33,6 +36,10 @@ final class ChannelRenderer /** * Whether the last rendered content ended on a newline, so a header can be kept on its own line * without inserting blank lines. + * + * Only tracks content this renderer produced. Callers write other things to the same sink — a + * test's result line, a batch header — which is why {@see reset()} restores the assumption: it is + * called at a boundary the caller has just terminated with a newline of its own. */ private bool $lastEndedWithNewline = true; @@ -56,7 +63,7 @@ public function render(Message $message): string } // Keep the header on its own line: break only if the previous content didn't already. - $separator = $this->lastChannel !== null && !$this->lastEndedWithNewline ? "\n" : ''; + $separator = $this->lastEndedWithNewline ? '' : "\n"; $this->lastChannel = $channel; $this->lastEndedWithNewline = \str_ends_with($content, "\n"); diff --git a/core/Output/Rendering/SharedStream.php b/core/Output/Rendering/SharedStream.php new file mode 100644 index 00000000..e1a46e33 --- /dev/null +++ b/core/Output/Rendering/SharedStream.php @@ -0,0 +1,160 @@ + + */ + private array $buffers = []; + + /** + * Finished blocks waiting for the stream, in the order they finished. Ids are not kept — nothing + * ever looks a block up, only their order matters, and each one already ends with the result line + * that names its test. + * + * @var list + */ + private array $queue = []; + + /** + * Whether the stream's tail is a newline, so a block never starts halfway through someone's line. + */ + private bool $atLineStart = true; + + /** + * @param resource $stream + */ + public function __construct($stream) + { + $this->stream = $stream; + } + + /** + * Write on behalf of `$owner`, or with no owner at all. + * + * @param int|null $owner The emitting test's run + * ({@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId}), so every data set of one + * test writes into the same block; `null` for output that belongs to no test, which is + * written through immediately. + */ + public function write(?int $owner, string $text): void + { + if ($text === '') { + return; + } + + if ($owner === null || $this->owner === $owner) { + $this->toStream($text); + return; + } + + if ($this->owner !== null) { + $this->buffers[$owner] = ($this->buffers[$owner] ?? '') . $text; + return; + } + + # The stream is free, so this test takes it. Whatever it buffered while someone else held the + # lease goes out as the head of the same block, so its output stays in one piece. + $this->owner = $owner; + $block = ($this->buffers[$owner] ?? '') . $text; + unset($this->buffers[$owner]); + $this->writeBlock($block); + } + + /** + * No more output for `$owner`: its block is complete. + * + * Goes out at once when the stream is free, and waits its turn otherwise. Closing the live writer + * releases the lease, which lets the waiting blocks out. + * + * @param int $owner {@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId} of the test that finished. + */ + public function close(int $owner): void + { + if ($this->owner === $owner) { + # Its block is already on the stream — there is nothing to queue, only a lease to give up. + $this->owner = null; + $this->drain(); + return; + } + + $block = $this->buffers[$owner] ?? ''; + unset($this->buffers[$owner]); + $block === '' or $this->queue[] = $block; + + $this->owner === null and $this->drain(); + } + + /** + * Put everything on the stream and start over — at a case or session boundary. + * + * Unlike {@see close()} this also drains blocks of tests that never finished: a test killed by a + * timeout or an aborted run still produced output, and losing it is worse than printing a block + * with no result line at its end. + */ + public function flush(): void + { + $this->owner = null; + $this->drain(); + + foreach ($this->buffers as $block) { + $this->writeBlock($block); + } + $this->buffers = []; + } + + private function drain(): void + { + foreach ($this->queue as $block) { + $this->writeBlock($block); + } + $this->queue = []; + } + + /** + * Opens a block on the stream, breaking the current line first so the block starts on its own. + */ + private function writeBlock(string $block): void + { + $this->atLineStart or $this->toStream("\n"); + $this->toStream($block); + } + + private function toStream(string $text): void + { + \fwrite($this->stream, $text); + $this->atLineStart = \str_ends_with($text, "\n"); + } +} diff --git a/core/Output/Teamcity/Teamcity/Formatter.php b/core/Output/Teamcity/Teamcity/Formatter.php index b9af5827..849e0cb7 100644 --- a/core/Output/Teamcity/Teamcity/Formatter.php +++ b/core/Output/Teamcity/Teamcity/Formatter.php @@ -4,6 +4,10 @@ namespace Testo\Output\Teamcity\Teamcity; +use Testo\Core\Context\Identity; +use Testo\Core\Context\Identity\CaseIdentity; +use Testo\Core\Context\Identity\TestIdentity; + /** * Formats TeamCity service messages. * @@ -17,41 +21,57 @@ */ final class Formatter { + /** + * Node every top-level one hangs under, as the IntelliJ id-based protocol fixes it. + * + * @see https://github.com/JetBrains/intellij-community/blob/master/platform/smRunner/src/com/intellij/execution/testframework/sm/runner/events/TreeNodeEvent.java + */ + private const ROOT_NODE = '0'; + private function __construct() {} + /** + * TeamCity `flowId` for a test: distinct concurrent tests get distinct flows, so their interleaved + * `testStarted`/output/`testFinished` messages stay grouped instead of overlapping on one stream. + * + * A data set answers its batch's flow rather than one of its own — the batch opened a nested suite + * that its data sets report inside, and a flow of their own would leave that suite behind. + * + * @return non-empty-string + */ + public static function flowId(TestIdentity $identity): string + { + return (string) $identity->pipelineId; + } + /** * Formats a test suite started message. * * @param non-empty-string $name Suite name - * @param \ReflectionClass|\ReflectionFunctionAbstract|null $reflection Class/function reflection for location hint - * @param \ReflectionClass|null $caseReflection Concrete (runtime) case class, used to - * attribute a method-backed suite (a DataProvider batch) to the subclass rather than the - * method's declaring class. Ignored when `$reflection` is a class. + * @param Identity|null $identity Address of the node this message opens — a suite of the run, a + * case, or the test behind a DataProvider batch node. {@see placement()} * @return non-empty-string */ - public static function suiteStarted(string $name, null|\ReflectionClass|\ReflectionFunctionAbstract $reflection = null, ?\ReflectionClass $caseReflection = null): string + public static function suiteStarted(string $name, ?Identity $identity = null): string { $attributes = ['name' => $name]; - if ($reflection !== null) { - $locationHint = $reflection instanceof \ReflectionClass - ? self::caseLocationHint($reflection) - : self::testLocationHint($reflection, caseReflection: $caseReflection); - $locationHint !== null and $attributes['locationHint'] = $locationHint; - } + $locationHint = self::locationHint($identity); + $locationHint === null or $attributes['locationHint'] = $locationHint; - return self::formatMessage('testSuiteStarted', $attributes); + return self::formatMessage('testSuiteStarted', $attributes + self::placement($identity)); } /** * Formats a test suite finished message. * * @param non-empty-string $name Suite name + * @param Identity|null $identity Address of the node this message closes. {@see placement()} * @return non-empty-string */ - public static function suiteFinished(string $name): string + public static function suiteFinished(string $name, ?Identity $identity = null): string { - return self::formatMessage('testSuiteFinished', ['name' => $name]); + return self::formatMessage('testSuiteFinished', ['name' => $name] + self::placement($identity)); } /** @@ -59,28 +79,24 @@ public static function suiteFinished(string $name): string * * @param non-empty-string $name Test name * @param bool $captureStandardOutput Whether to capture standard output - * @param \ReflectionFunctionAbstract|null $reflection Function/method reflection for location hint - * @param non-empty-string|null $locationSuffix Optional suffix to append to location hint (e.g., " with data set #0") + * @param TestIdentity|null $identity Address to point the location hint at. When it addresses a data + * set, the hint carries the coordinates — no separate suffix is needed. * @param non-empty-string|null $description Test description (from the PHPDoc summary), emitted as * the TeamCity `metainfo` attribute. Omitted when `null`. - * @param \ReflectionClass|null $caseReflection Concrete (runtime) case class, used to - * attribute an inherited test to the subclass rather than the method's declaring class. * @return non-empty-string */ - public static function testStarted(string $name, bool $captureStandardOutput = false, ?\ReflectionFunctionAbstract $reflection = null, ?string $locationSuffix = null, ?string $description = null, ?\ReflectionClass $caseReflection = null): string + public static function testStarted(string $name, bool $captureStandardOutput = false, ?TestIdentity $identity = null, ?string $description = null): string { $attributes = ['name' => $name]; $captureStandardOutput and $attributes['captureStandardOutput'] = 'true'; - if ($reflection !== null) { - $locationHint = self::testLocationHint($reflection, $locationSuffix, $caseReflection); - $locationHint !== null and $attributes['locationHint'] = $locationHint; - } + $locationHint = self::locationHint($identity); + $locationHint === null or $attributes['locationHint'] = $locationHint; $description !== null and $attributes['metainfo'] = $description; - return self::formatMessage('testStarted', $attributes); + return self::formatMessage('testStarted', $attributes + self::placement($identity)); } /** @@ -88,15 +104,16 @@ public static function testStarted(string $name, bool $captureStandardOutput = f * * @param non-empty-string $name Test name * @param int<0, max>|null $duration Duration in milliseconds + * @param TestIdentity|null $identity Address of the test this message closes. {@see placement()} * @return non-empty-string */ - public static function testFinished(string $name, ?int $duration = null): string + public static function testFinished(string $name, ?int $duration = null, ?TestIdentity $identity = null): string { $attributes = ['name' => $name]; $duration !== null and $attributes['duration'] = (string) $duration; - return self::formatMessage('testFinished', $attributes); + return self::formatMessage('testFinished', $attributes + self::placement($identity)); } /** @@ -108,6 +125,7 @@ public static function testFinished(string $name, ?int $duration = null): string * @param non-empty-string|null $type Comparison type for diff display (e.g., 'comparisonFailure') * @param non-empty-string|null $expected Expected value for diff * @param non-empty-string|null $actual Actual value for diff + * @param TestIdentity|null $identity Address of the test that failed. {@see placement()} * @return non-empty-string */ public static function testFailed( @@ -117,6 +135,7 @@ public static function testFailed( ?string $type = null, ?string $expected = null, ?string $actual = null, + ?TestIdentity $identity = null, ): string { $attributes = [ 'name' => $name, @@ -128,7 +147,7 @@ public static function testFailed( $expected !== null and $attributes['expected'] = $expected; $actual !== null and $attributes['actual'] = $actual; - return self::formatMessage('testFailed', $attributes); + return self::formatMessage('testFailed', $attributes + self::placement($identity)); } /** @@ -136,15 +155,16 @@ public static function testFailed( * * @param non-empty-string $name Test name * @param non-empty-string $message Optional skip reason + * @param TestIdentity|null $identity Address of the test that was skipped. {@see placement()} * @return non-empty-string */ - public static function testIgnored(string $name, string $message = ''): string + public static function testIgnored(string $name, string $message = '', ?TestIdentity $identity = null): string { $attributes = ['name' => $name]; $message !== '' and $attributes['message'] = $message; - return self::formatMessage('testIgnored', $attributes); + return self::formatMessage('testIgnored', $attributes + self::placement($identity)); } /** @@ -154,14 +174,15 @@ public static function testIgnored(string $name, string $message = ''): string * @param non-empty-string $output Standard output content * @param array $attributes Extra attributes (e.g. `channel`, `level`) * for consumers that understand them; standard TeamCity parsers ignore unknown ones. + * @param TestIdentity|null $identity Address of the test the output came from. {@see placement()} * @return non-empty-string */ - public static function testStdOut(string $name, string $output, array $attributes = []): string + public static function testStdOut(string $name, string $output, array $attributes = [], ?TestIdentity $identity = null): string { return self::formatMessage('testStdOut', [ 'name' => $name, 'out' => $output, - ] + $attributes); + ] + $attributes + self::placement($identity)); } /** @@ -171,14 +192,16 @@ public static function testStdOut(string $name, string $output, array $attribute * @param non-empty-string $output Standard error content * @param array $attributes Extra attributes (e.g. `channel`, `level`) * for consumers that understand them; standard TeamCity parsers ignore unknown ones. + * @param Identity|null $identity Address of the node the output came from — a test, or the suite or + * case whose own failure this reports. {@see placement()} * @return non-empty-string */ - public static function testStdErr(string $name, string $output, array $attributes = []): string + public static function testStdErr(string $name, string $output, array $attributes = [], ?Identity $identity = null): string { return self::formatMessage('testStdErr', [ 'name' => $name, 'out' => $output, - ] + $attributes); + ] + $attributes + self::placement($identity)); } /** @@ -370,65 +393,60 @@ private static function escape(string $value): string } /** - * Generates location hint for a test case from reflection. + * Where in the run a message belongs: which node it is about, and which flow carries it. * - * Format: php_qn://path/to/file.php::\ClassName + * `nodeId`/`parentNodeId` state the tree outright, which is the only way a consumer gets it right + * when tests run concurrently — one that nests by whatever opened last puts an interleaved batch's + * node inside its neighbour's. The ids are the run numbers off the address + * ({@see Identity::$runtimeId}, {@see Identity::$parentId}), so a node keeps its identity across + * every message about it, and a level with no parent hangs under {@see ROOT_NODE}. * - * @param \ReflectionClass $reflection - * @return non-empty-string|null + * `flowId` is TeamCity's own grouping and applies to tests only: suites and cases of one process + * never overlap, so there is nothing to tell apart. {@see flowId()} + * + * @return array */ - private static function caseLocationHint(\ReflectionClass $reflection): ?string + private static function placement(?Identity $identity): array { - $file = $reflection->getFileName(); - $className = $reflection->getName(); + if ($identity === null) { + return []; + } + + $placement = [ + 'nodeId' => (string) $identity->runtimeId, + 'parentNodeId' => (string) ($identity->parentId ?? self::ROOT_NODE), + ]; - return $file !== false - ? \sprintf('php_qn://%s::\\%s', $file, $className) - : null; + $identity instanceof TestIdentity and $placement['flowId'] = self::flowId($identity); + + return $placement; } /** - * Generates location hint for a test method/function from reflection. - * - * Format: php_qn://path/to/file.php::\ClassName::methodName (for methods) - * Format: php_qn://path/to/file.php::functionName (for functions) - * Format: php_qn://path/to/file.php::\ClassName::methodName with data set #0 (with suffix) - * - * @param non-empty-string|null $suffix Optional suffix to append (e.g., " with data set #0") - * @param \ReflectionClass|null $caseReflection Concrete (runtime) case class. When given - * for a method-backed test, the hint points at this class (name and file) instead of the - * method's declaring class. For a `#[Test]` inherited from an abstract base, - * getDeclaringClass() names the base — but the enclosing testSuite is named after the - * concrete subclass (see {@see caseLocationHint}), so TeamCity would otherwise file the - * inherited test under the abstract class. Mirrors JUnitWriter::classnameFor() and - * CoverageTestInterceptor::buildMethodId(). + * Location hint for whatever the address names. + * + * ``` + * php_qn://path/to/BarTest.php::\Ns\BarTest a case + * php_qn://path/to/BarTest.php::\Ns\BarTest::itWorks a test, or its DataProvider batch node + * php_qn://path/to/BarTest.php::\Ns\BarTest::itWorks:0:1 one data set of it + * php_qn://path/to/functions.php::\Ns\itWorksToo a free test function + * ``` + * + * The tail is {@see TestIdentity::fqn()} verbatim, so a hint pastes straight back into `--filter`. + * + * Null when there is no code to point at: a suite of the run is a configuration entry, and a case + * of free functions has no class of its own. + * * @return non-empty-string|null */ - private static function testLocationHint(\ReflectionFunctionAbstract $reflection, ?string $suffix = null, ?\ReflectionClass $caseReflection = null): ?string + private static function locationHint(?Identity $identity): ?string { - $name = $reflection->getName(); - - // For methods, include class name - if ($reflection instanceof \ReflectionMethod) { - $class = $caseReflection ?? $reflection->getDeclaringClass(); - $file = $class->getFileName(); - - if ($file === false) { - return null; - } - - $locationHint = \sprintf('php_qn://%s::\\%s::%s', $file, $class->getName(), $name); - } else { - $file = $reflection->getFileName(); - - if ($file === false) { - return null; - } - - // For functions, just the function name - $locationHint = \sprintf('php_qn://%s::%s', $file, "\\$name"); + if (!$identity instanceof CaseIdentity && !$identity instanceof TestIdentity) { + return null; } - return $suffix !== null ? $locationHint . $suffix : $locationHint; + $fqn = $identity->fqn(); + + return $fqn === null ? null : "php_qn://{$identity->file}::\\{$fqn}"; } } diff --git a/core/Output/Teamcity/Teamcity/TeamcityLogger.php b/core/Output/Teamcity/Teamcity/TeamcityLogger.php index 527e9916..6ade50e6 100644 --- a/core/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/core/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -12,6 +12,7 @@ use Testo\Core\Context\CaseResult; use Testo\Core\Context\SuiteInfo; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Log\Message; @@ -109,7 +110,7 @@ public function logEnvironment(): void */ public function suiteStartedFromInfo(SuiteInfo $info): void { - $this->publish(Formatter::suiteStarted($info->name)); + $this->publish(Formatter::suiteStarted($info->name, $info->identity)); } /** @@ -117,7 +118,7 @@ public function suiteStartedFromInfo(SuiteInfo $info): void */ public function suiteFinishedFromInfo(SuiteInfo $info): void { - $this->publish(Formatter::suiteFinished($info->name)); + $this->publish(Formatter::suiteFinished($info->name, $info->identity)); } /** @@ -125,7 +126,7 @@ public function suiteFinishedFromInfo(SuiteInfo $info): void */ public function batchStartedFromInfo(TestInfo $info): void { - $this->publish(Formatter::suiteStarted($info->name, $info->testDefinition->reflection, $info->caseInfo->definition->reflection)); + $this->publish(Formatter::suiteStarted($info->name, $info->identity)); } /** @@ -133,7 +134,7 @@ public function batchStartedFromInfo(TestInfo $info): void */ public function batchFinishedFromInfo(TestInfo $info): void { - $this->publish(Formatter::suiteFinished($info->name)); + $this->publish(Formatter::suiteFinished($info->name, $info->identity)); } /** @@ -150,6 +151,7 @@ public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void Formatter::testStdErr( $info->name, "Test suite failed: {$failedCount} test(s) failed", + identity: $info->identity, ), ); } @@ -164,7 +166,7 @@ public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void */ public function caseStartedFromInfo(CaseInfo $info): void { - $this->publish(Formatter::suiteStarted($info->name, $info->definition->reflection)); + $this->publish(Formatter::suiteStarted($info->name, $info->identity)); } /** @@ -174,7 +176,7 @@ public function caseStartedFromInfo(CaseInfo $info): void */ public function caseFinishedFromInfo(CaseInfo $info): void { - $this->publish(Formatter::suiteFinished($info->name)); + $this->publish(Formatter::suiteFinished($info->name, $info->identity)); } /** @@ -193,6 +195,7 @@ public function handleCaseResult(CaseInfo $caseInfo, CaseResult $result, ?int $d Formatter::testStdErr( $caseInfo->name, "Test case failed: {$failedCount} test(s) failed", + identity: $caseInfo->identity, ), ); } @@ -205,7 +208,7 @@ public function handleCaseResult(CaseInfo $caseInfo, CaseResult $result, ?int $d * * If the test has DataProvider (MultipleResult), starts it as a test suite. */ - public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput = false, ?string $overrideName = null, ?string $locationSuffix = null): void + public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput = false, ?string $overrideName = null): void { $description = $info->testDefinition->getDescription(); $description === '' and $description = null; @@ -213,10 +216,8 @@ public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput $this->publish(Formatter::testStarted( $overrideName ?? $info->name, $captureStandardOutput, - $info->testDefinition->reflection, - $locationSuffix, + $info->identity, $description, - $info->caseInfo->definition->reflection, )); } @@ -227,7 +228,7 @@ public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput */ public function testFinishedFromInfo(TestInfo $info, ?int $duration = null): void { - $this->publish(Formatter::testFinished($info->name, $duration)); + $this->publish(Formatter::testFinished($info->name, $duration, $info->identity)); } /** @@ -252,6 +253,7 @@ public function testFailedFromResult(TestResult $result): void type: $isComparison ? 'comparisonFailure' : null, expected: $isComparison ? $failure->getExpectedAsString() : null, actual: $isComparison ? $failure->getActualAsString() : null, + identity: $result->info->identity, ), ); } @@ -263,7 +265,7 @@ public function testFailedFromResult(TestResult $result): void */ public function testIgnoredFromInfo(TestInfo $info, string $message = ''): void { - $this->publish(Formatter::testIgnored($info->name, $message)); + $this->publish(Formatter::testIgnored($info->name, $message, $info->identity)); } /** @@ -276,8 +278,10 @@ public function testIgnoredFromInfo(TestInfo $info, string $message = ''): void * `testFinished` to nest correctly. * * @param non-empty-string $name Test name the message belongs to. + * @param TestIdentity|null $identity Address of the test the output came from, so interleaved output + * lands on the right node instead of wherever the stream happens to be. */ - public function logMessage(string $name, Message $message): void + public function logMessage(string $name, Message $message, ?TestIdentity $identity = null): void { if ($message->content === '') { return; @@ -291,8 +295,8 @@ public function logMessage(string $name, Message $message): void ]; $this->publish($message->channel === self::CHANNEL_STDERR - ? Formatter::testStdErr($name, $message->content, $attributes) - : Formatter::testStdOut($name, $message->content, $attributes)); + ? Formatter::testStdErr($name, $message->content, $attributes, $identity) + : Formatter::testStdOut($name, $message->content, $attributes, $identity)); } /** @@ -383,7 +387,7 @@ private function handlePassedTest(TestResult $result, ?int $duration, ?string $o { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $result->info->identity)); } /** @@ -394,8 +398,9 @@ private function handlePassedTest(TestResult $result, ?int $duration, ?string $o private function handleSkippedTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testIgnored($name)); - $this->publish(Formatter::testFinished($name, $duration)); + $identity = $result->info->identity; + $this->publish(Formatter::testIgnored($name, identity: $identity)); + $this->publish(Formatter::testFinished($name, $duration, $identity)); } /** @@ -406,8 +411,9 @@ private function handleSkippedTest(TestResult $result, ?int $duration, ?string $ private function handleCancelledTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testIgnored($name, 'Test cancelled')); - $this->publish(Formatter::testFinished($name, $duration)); + $identity = $result->info->identity; + $this->publish(Formatter::testIgnored($name, 'Test cancelled', $identity)); + $this->publish(Formatter::testFinished($name, $duration, $identity)); } /** @@ -426,6 +432,7 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o : ''; $isComparison = $failure instanceof ComparisonFailure; + $identity = $result->info->identity; $this->publish( Formatter::testFailed( @@ -435,9 +442,10 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o type: $isComparison ? 'comparisonFailure' : null, expected: $isComparison ? $failure->getExpectedAsString() : null, actual: $isComparison ? $failure->getActualAsString() : null, + identity: $identity, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $identity)); } /** @@ -452,15 +460,17 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ $details = $result->failure !== null ? self::formatThrowable($result->failure, $result->info->testDefinition->reflection) : ''; + $identity = $result->info->identity; $this->publish( Formatter::testFailed( $name, 'Test aborted', $details, + identity: $identity, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $identity)); } /** @@ -471,14 +481,16 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ private function handleRiskyTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; + $identity = $result->info->identity; $this->publish( Formatter::testStdOut( $name, "\nWarning: This test has been marked as risky", + identity: $identity, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $identity)); } /** diff --git a/core/Output/Teamcity/TeamcityPlugin.php b/core/Output/Teamcity/TeamcityPlugin.php index 8fa9b25f..cde3118d 100644 --- a/core/Output/Teamcity/TeamcityPlugin.php +++ b/core/Output/Teamcity/TeamcityPlugin.php @@ -27,27 +27,33 @@ final class TeamcityPlugin implements PluginConfigurator { /** - * Tracks whether we're inside a DataProvider batch. + * Ids of the tests currently inside a DataProvider batch. * - * @var array + * Keyed by {@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId} so concurrently running + * tests are tracked independently — a single scalar would be clobbered the moment two tests + * interleave — and so a batch's data sets, whose own runs differ, find the batch's entry. + * + * @var array */ private array $isBatch = []; /** - * Name of the in-flight test/data set that streamed {@see MessageReceived} output is attributed - * to. `null` when no test is running (output between tests is dropped). + * Name of the in-flight test/data set that streamed {@see MessageReceived} output is attributed to, + * per running test id. A test with no entry is not running (its output is dropped). * - * @var non-empty-string|null + * @var array */ - private ?string $currentName = null; + private array $currentName = []; /** - * A regular (non-DataProvider) test whose `testStarted` has not been emitted yet. It is emitted - * lazily on the first message (so output can stream in real time), or at pipeline finish if the - * test produced no output. `null` once `testStarted` has been emitted (or for data sets, which - * emit it eagerly). + * Regular (non-DataProvider) tests whose `testStarted` has not been emitted yet, per test id. Emitted + * lazily on the first message (so output streams in real time), or at pipeline finish if the test + * produced no output. Dropped once `testStarted` has been emitted (or for data sets, which emit it + * eagerly). + * + * @var array */ - private ?TestInfo $pendingStart = null; + private array $pendingStart = []; private readonly TeamcityLogger $logger; @@ -89,18 +95,12 @@ public function configure(Container $container): void $listeners->addListener(TestSuiteFinished::class, $this->onTestSuiteFinished(...)); } - private static function getId(TestInfo $testInfo): string - { - return \spl_object_hash($testInfo->testDefinition); - } - /** - * Clears the current-test attribution so output emitted outside any test is dropped. + * Clears one test's attribution once it is done, so later output outside any test is dropped. */ - private function resetCurrent(): void + private function resetCurrent(int $id): void { - $this->currentName = null; - $this->pendingStart = null; + unset($this->currentName[$id], $this->pendingStart[$id]); } private function onSessionStarting(SessionStarting $event): void @@ -117,62 +117,68 @@ private function onSessionFinished(SessionFinished $event): void private function onMessageReceived(MessageReceived $event): void { - // No test in flight — output between tests is not attributable, so drop it. Internal errors on - // the dedicated stderr channel are the exception: surface them as a standalone message instead. - if ($this->currentName === null) { + $identity = $event->identity; + + // No attributable test — the message belongs to none (suite/case setup, output between tests), + // or its test is not tracked here. Drop it; internal errors on the dedicated stderr channel are + // the exception and are surfaced as a standalone message instead. + if ($identity === null || !isset($this->currentName[$identity->pipelineId])) { $event->message->channel === Messenger::CHANNEL_STDERR and $this->logger->logStandaloneMessage($event->message); return; } + $id = $identity->pipelineId; + // Lazily emit testStarted for a regular test on its first output, so it streams in real time. - if ($this->pendingStart !== null) { - $this->logger->testStartedFromInfo($this->pendingStart); - $this->pendingStart = null; + if (isset($this->pendingStart[$id])) { + $this->logger->testStartedFromInfo($this->pendingStart[$id]); + unset($this->pendingStart[$id]); } - $this->logger->logMessage($this->currentName, $event->message); + $this->logger->logMessage($this->currentName[$id], $event->message, $identity); } private function onTestPipelineStarting(TestPipelineStarting $event): void { // Assume a regular test: attribute output to it and keep testStarted pending until output // arrives. If it turns out to be a DataProvider batch, onTestBatchStarting clears this. - $this->currentName = $event->testInfo->name; - $this->pendingStart = $event->testInfo; + $id = $event->testInfo->identity->pipelineId; + $this->currentName[$id] = $event->testInfo->name; + $this->pendingStart[$id] = $event->testInfo; } private function onTestPipelineFinished(TestPipelineFinished $event): void { // Check if this test was inside a DataProvider batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->pipelineId; if (isset($this->isBatch[$id])) { // DataProvider test - already handled in batch events unset($this->isBatch[$id]); - $this->resetCurrent(); + $this->resetCurrent($id); return; } // Regular test: testStarted was emitted lazily on first output; if there was none, emit now. - if ($this->pendingStart !== null) { - $this->logger->testStartedFromInfo($this->pendingStart); - $this->pendingStart = null; + if (isset($this->pendingStart[$id])) { + $this->logger->testStartedFromInfo($this->pendingStart[$id]); + unset($this->pendingStart[$id]); } $duration = (int) $event->testResult->getAttribute('duration'); $this->logger->handleSingleTestResult($event->testResult, $duration); - $this->resetCurrent(); + $this->resetCurrent($id); } private function onTestBatchStarting(TestBatchStarting $event): void { // Mark that we're inside a batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->pipelineId; $this->isBatch[$id] = true; // It's a DataProvider, not a single test: drop the pending single-test start; data sets // emit their own testStarted and own the current attribution. - $this->resetCurrent(); + $this->resetCurrent($id); // For DataProvider tests, start a test suite (wraps all data sets) $this->logger->batchStartedFromInfo($event->testInfo); @@ -188,19 +194,18 @@ private function onTestDataSetStarting(TestDataSetStarting $event): void { // Send testStarted for individual dataset within DataProvider $prefix = $event->providerIndex === null ? '' : "$event->providerIndex:"; - $locationSuffix = $event->providerIndex !== null - ? ":$event->dataSetKey:$event->providerIndex" - : ":$event->dataSetKey"; $name = "Dataset #{$prefix}{$event->datasetIndex} [$event->dataSetKey]"; - $this->logger->testStartedFromInfo( - $event->testInfo, - overrideName: $name, - locationSuffix: $locationSuffix, - ); - // testStarted already emitted eagerly; stream this data set's output to it in real time. - $this->currentName = $name; - $this->pendingStart = null; + # The info's address already points at the data set, so the location hint carries the same + # coordinates `--filter` takes. + $this->logger->testStartedFromInfo($event->testInfo, overrideName: $name); + + // testStarted already emitted eagerly; stream this data set's output to it in real time. Data + // sets of one batch share its run and go one at a time, so one current-name entry per run is + // unambiguous — this one replaces the previous data set's. + $id = $event->testInfo->identity->pipelineId; + $this->currentName[$id] = $name; + unset($this->pendingStart[$id]); } private function onTestDataSetFinished(TestDataSetFinished $event): void @@ -211,7 +216,7 @@ private function onTestDataSetFinished(TestDataSetFinished $event): void $name = "Dataset #{$prefix}{$event->datasetIndex} [$event->datasetKey]"; $this->logger->handleSingleTestResult($event->testResult, $duration, overrideName: $name); - $this->resetCurrent(); + $this->resetCurrent($event->testInfo->identity->pipelineId); } private function onTestCaseStarting(TestCaseStarting $event): void @@ -222,6 +227,12 @@ private function onTestCaseStarting(TestCaseStarting $event): void private function onTestCaseFinished(TestCaseFinished $event): void { $this->logger->handleCaseResult($event->caseInfo, $event->caseResult); + + // No test spans a case boundary, so anything still tracked belongs to a test the runner never + // finished (a hang, an abort) — dropped here so a long session does not accumulate it. + $this->isBatch = []; + $this->currentName = []; + $this->pendingStart = []; } private function onTestSuiteStarting(TestSuiteStarting $event): void diff --git a/core/Output/Terminal/Renderer/TerminalLogger.php b/core/Output/Terminal/Renderer/TerminalLogger.php index f190c176..5283c1b6 100644 --- a/core/Output/Terminal/Renderer/TerminalLogger.php +++ b/core/Output/Terminal/Renderer/TerminalLogger.php @@ -20,6 +20,7 @@ use Testo\Core\Value\Verbosity; use Testo\Data\MultipleResult; use Testo\Output\Rendering\ChannelRenderer; +use Testo\Output\Rendering\SharedStream; /** * Terminal logger for test reporting with configurable output format. @@ -32,16 +33,20 @@ final class TerminalLogger private array $failures = []; /** - * Current indentation level for nested tests (e.g., DataProvider datasets). + * Indentation level of each test in flight (nested for DataProvider datasets), keyed by + * {@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId}. A single counter is clobbered the moment two + * tests interleave: the one entering a batch would indent the other's lines. * - * @var int<0, max> + * @var array> */ - private int $currentIndentLevel = 0; + private array $currentIndentLevel = []; /** - * Override name for the current test (e.g., dataset name). + * Override name of each test in flight (e.g. the running data set's name), keyed by test id. + * + * @var array */ - private ?string $currentTestName = null; + private array $currentTestName = []; /** * Current suite name for failure context. @@ -55,13 +60,24 @@ final class TerminalLogger */ private ?TestResult $lastResult = null; - /** @var resource */ - private $output; + private readonly SharedStream $out; /** @var resource */ private $errorOutput; - private readonly ChannelRenderer $channels; + /** + * Channel grouping of each open block, keyed by test id. Its life is the block's life — created on + * the test's first write, dropped by {@see closeTest()} — so it never carries state across into + * another test's block, and a block always opens with a fresh channel header. + * + * @var array + */ + private array $channels = []; + + /** + * Channel grouping of output that belongs to no test, which is written through rather than blocked. + */ + private ?ChannelRenderer $unownedChannels = null; /** * @param resource|null $output Stream for the human-facing report; defaults to {@see \STDOUT}. @@ -77,9 +93,8 @@ public function __construct( $output = null, $errorOutput = null, ) { - $this->output = $output ?? \STDOUT; + $this->out = new SharedStream($output ?? \STDOUT); $this->errorOutput = $errorOutput ?? \STDERR; - $this->channels = new ChannelRenderer(); } /** @@ -88,7 +103,7 @@ public function __construct( public function suiteStartedFromInfo(SuiteInfo $info): void { $this->currentSuiteName = $info->name; - $this->write(Formatter::suiteHeader($info->name, $this->format)); + $this->write(null, Formatter::suiteHeader($info->name, $this->format)); } /** @@ -96,7 +111,7 @@ public function suiteStartedFromInfo(SuiteInfo $info): void */ public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void { - $this->write(Formatter::suiteSummary($result)); + $this->write(null, Formatter::suiteSummary($result)); } /** @@ -104,7 +119,7 @@ public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void */ public function caseStartedFromInfo(CaseInfo $info): void { - $this->write(Formatter::caseHeader($info->name, $this->format)); + $this->write(null, Formatter::caseHeader($info->name, $this->format)); } /** @@ -112,8 +127,16 @@ public function caseStartedFromInfo(CaseInfo $info): void */ public function handleCaseResult(CaseInfo $info, CaseResult $result): void { - $this->write(Formatter::caseFooter($this->format)); - $this->write(Formatter::caseSummary($result, $this->format)); + # Every test of the case has finished by now, so nothing should still be held — but a test the + # runner never closed would otherwise land under the *next* case's header. Its per-test state + # goes with it, so a long session does not accumulate entries no close will ever drop. + $this->out->flush(); + $this->channels = []; + $this->currentTestName = []; + $this->currentIndentLevel = []; + + $this->write(null, Formatter::caseFooter($this->format)); + $this->write(null, Formatter::caseSummary($result, $this->format)); } /** @@ -121,7 +144,7 @@ public function handleCaseResult(CaseInfo $info, CaseResult $result): void */ public function batchStartedFromInfo(TestInfo $info): void { - $this->currentIndentLevel = 1; + $this->currentIndentLevel[$info->identity->pipelineId] = 1; if ($this->format === OutputFormat::Dots) { return; @@ -130,11 +153,12 @@ public function batchStartedFromInfo(TestInfo $info): void // Print the batch test name (the main test with DataProvider) $indent = $this->format === OutputFormat::Verbose ? ' ' : ' '; $symbol = Style::dim(Symbol::DataProvider->value); - $this->write("{$indent}{$symbol} {$info->name}\n"); + $id = $info->identity->pipelineId; + $this->write($id, "{$indent}{$symbol} {$info->name}\n"); // The description belongs to the test, not to each dataset — print it once here, at the root // of the dataset tree, so it is not repeated under every dataset (see handle*Test). - $this->write(Formatter::description( + $this->write($id, Formatter::description( (string) $info->testDefinition->getDescription(), 0, $this->format, @@ -146,7 +170,7 @@ public function batchStartedFromInfo(TestInfo $info): void */ public function batchFinishedFromInfo(TestInfo $info): void { - $this->currentIndentLevel = 0; + unset($this->currentIndentLevel[$info->identity->pipelineId]); // No visual output for batch finish in terminal mode } @@ -157,28 +181,53 @@ public function batchFinishedFromInfo(TestInfo $info): void */ public function testStartedFromInfo(TestInfo $info, ?string $overrideName = null): void { - $this->currentTestName = $overrideName; + $id = $info->identity->pipelineId; + if ($overrideName === null) { + # A regular test carries no override — it prints under its own name. + unset($this->currentTestName[$id]); + } else { + $this->currentTestName[$id] = $overrideName; + } // No output on test start for compact/dots mode } /** - * Resets channel grouping so the next test's first message prints a fresh channel header. + * Resets the test's channel grouping so its next message prints a fresh channel header. Used at + * data set boundaries: data sets share the batch's block, so nothing else would separate them. */ - public function resetChannels(): void + public function resetChannels(TestInfo $info): void { - $this->channels->reset(); + ($this->channels[$info->identity->pipelineId] ?? null)?->reset(); + } + + /** + * The test is done writing: release its block onto the stream and forget its channel grouping. + * + * Must come after the last write for the test — its result line included — or that line would be + * carried over into whatever block opens next. + */ + public function closeTest(TestInfo $info): void + { + $id = $info->identity->pipelineId; + unset($this->channels[$id]); + $this->out->close($id); } /** * Streams a captured channel {@see Message} to the terminal in real time. * - * A colored channel header (name + time of the channel's first message) is printed when the - * channel changes; same-channel content is appended verbatim. Live streaming happens only at + * A colored channel header (name + time of the group's first message) is printed when the group + * changes; same-group content is appended verbatim. Live streaming happens only at * {@see Verbosity::Verbose} and above; at lower verbosity the output of a *failed* test is shown * after the fact (see {@see printFailures()}). Suppressed in Dots mode, whose single-character * per-test layout multi-line output would break. + * + * @param int|null $owner Run of the test the message belongs to + * ({@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId}), which decides both the + * block it lands in and the channel grouping it continues. `null` for output owned by no + * test. */ - public function logMessage(Message $message): void + public function logMessage(Message $message, ?int $owner = null): void { if ($message->content === '') { return; @@ -198,7 +247,7 @@ public function logMessage(Message $message): void return; } - $this->write($this->channels->render($message)); + $this->write($owner, $this->channelsFor($owner)->render($message)); } /** @@ -223,6 +272,10 @@ public function handleTestResult(TestResult $result, ?int $duration): void */ public function printSummary(RunResult $result): void { + # Last chance for anything still held — a test the run never closed (aborted, timed out) must + # not be swallowed by the end of the report. + $this->out->flush(); + $this->printFailures(); $this->printSingleTestOutput($result); $this->printStatistics($result); @@ -233,13 +286,13 @@ public function printSummary(RunResult $result): void */ public function ensureHeader(): void { - $this->write(Formatter::runHeader()); + $this->write(null, Formatter::runHeader()); } public function printEnvironment(): void { - $this->write(\sprintf(' %s %s (%s)', Style::info('OS:'), Environment::getOs(), Environment::getCpu()) . "\n"); - $this->write(\sprintf(' %s %s (%s, memory: %s)', Style::info('PHP:'), Environment::getPhpVersion(), \PHP_SAPI, \ini_get('memory_limit') ?: 'unlimited') . "\n"); + $this->write(null, \sprintf(' %s %s (%s)', Style::info('OS:'), Environment::getOs(), Environment::getCpu()) . "\n"); + $this->write(null, \sprintf(' %s %s (%s, memory: %s)', Style::info('PHP:'), Environment::getPhpVersion(), \PHP_SAPI, \ini_get('memory_limit') ?: 'unlimited') . "\n"); $modes = Environment::getXDebugMode(); $xdebug = match (true) { @@ -247,14 +300,14 @@ public function printEnvironment(): void $modes !== [] => Environment::getXDebugVersion() . Style::dim(' (' . \implode(', ', $modes) . ')'), default => Environment::getXDebugVersion() . Style::dim(' (off)'), }; - $this->write(\sprintf(' %s %s', Style::info('XDebug:'), $xdebug) . "\n"); + $this->write(null, \sprintf(' %s %s', Style::info('XDebug:'), $xdebug) . "\n"); $opcache = match (true) { !Environment::isOpCacheEnabled() => 'off', Environment::isJitEnabled() => 'enabled with JIT', default => 'enabled', }; - $this->write(\sprintf(' %s %s', Style::info('OPcache:'), $opcache) . "\n\n"); + $this->write(null, \sprintf(' %s %s', Style::info('OPcache:'), $opcache) . "\n\n"); } /** @@ -326,12 +379,26 @@ private function printSingleTestOutput(RunResult $result): void } $output = self::renderMessages($test->messages); - $output === '' or $this->write("\n" . $output); + $output === '' or $this->write(null, "\n" . $output); } - private function write(string $text): void + /** + * @param int|null $owner Test whose block the text belongs to; `null` for report structure that + * belongs to no test (suite header, case footer, final summary). + */ + private function write(?int $owner, string $text): void { - \fwrite($this->output, $text); + $this->out->write($owner, $text); + } + + /** + * Channel grouping for the given owner's block, opened on demand. + */ + private function channelsFor(?int $owner): ChannelRenderer + { + return $owner === null + ? $this->unownedChannels ??= new ChannelRenderer() + : $this->channels[$owner] ??= new ChannelRenderer(); } /** @@ -342,16 +409,16 @@ private function write(string $text): void private function handlePassedTest(TestResult $result, ?int $duration): void { $item = new FormattedItem( - name: $this->currentTestName ?? $result->info->name, + name: $this->displayName($result), status: $result->status, duration: $duration, - indentLevel: $this->currentIndentLevel, + indentLevel: $this->indentLevel($result), description: $this->resultDescription($result), ); - $this->write(Formatter::formatRun($item, $this->format)); + $this->write($result->info->identity->pipelineId, Formatter::formatRun($item, $this->format)); $this->printMultipleRuns($result); - $this->currentTestName = null; + unset($this->currentTestName[$result->info->identity->pipelineId]); } /** @@ -365,20 +432,40 @@ private function handleFailedTest(TestResult $result, ?int $duration): void 'result' => $result, 'duration' => $duration, 'suiteName' => $this->currentSuiteName, - 'datasetName' => $this->currentTestName, + 'datasetName' => $this->currentTestName[$result->info->identity->pipelineId] ?? null, ]; $item = new FormattedItem( - name: $this->currentTestName ?? $result->info->name, + name: $this->displayName($result), status: $result->status, duration: $duration, - indentLevel: $this->currentIndentLevel, + indentLevel: $this->indentLevel($result), description: $this->resultDescription($result), ); - $this->write(Formatter::formatRun($item, $this->format)); + $this->write($result->info->identity->pipelineId, Formatter::formatRun($item, $this->format)); $this->printMultipleRuns($result); - $this->currentTestName = null; + unset($this->currentTestName[$result->info->identity->pipelineId]); + } + + /** + * Name to print for a finished test: the override recorded for it (a data set's name), or its own. + * + * @return non-empty-string + */ + private function displayName(TestResult $result): string + { + return $this->currentTestName[$result->info->identity->pipelineId] ?? $result->info->name; + } + + /** + * Indentation recorded for the test — 1 inside its DataProvider batch, 0 at the top level. + * + * @return int<0, max> + */ + private function indentLevel(TestResult $result): int + { + return $this->currentIndentLevel[$result->info->identity->pipelineId] ?? 0; } /** @@ -388,7 +475,7 @@ private function handleFailedTest(TestResult $result, ?int $duration): void */ private function resultDescription(TestResult $result): string { - return $this->currentIndentLevel > 0 ? '' : (string) $result->getAttribute('description'); + return $this->indentLevel($result) > 0 ? '' : (string) $result->getAttribute('description'); } /** @@ -416,7 +503,7 @@ private function printMultipleRuns(TestResult $result): void description: (string) $runKey, ); - $this->write(Formatter::formatRun($item, $this->format)); + $this->write($result->info->identity->pipelineId, Formatter::formatRun($item, $this->format)); $runNumber++; } } @@ -429,14 +516,14 @@ private function printMultipleRuns(TestResult $result): void private function handleSkippedTest(TestResult $result, ?int $duration): void { $item = new FormattedItem( - name: $this->currentTestName ?? $result->info->name, + name: $this->displayName($result), status: $result->status, duration: $duration, - indentLevel: $this->currentIndentLevel, + indentLevel: $this->indentLevel($result), ); - $this->write(Formatter::formatRun($item, $this->format)); - $this->currentTestName = null; + $this->write($result->info->identity->pipelineId, Formatter::formatRun($item, $this->format)); + unset($this->currentTestName[$result->info->identity->pipelineId]); } /** @@ -447,14 +534,14 @@ private function handleSkippedTest(TestResult $result, ?int $duration): void private function handleRiskyTest(TestResult $result, ?int $duration): void { $item = new FormattedItem( - name: $this->currentTestName ?? $result->info->name, + name: $this->displayName($result), status: $result->status, duration: $duration, - indentLevel: $this->currentIndentLevel, + indentLevel: $this->indentLevel($result), ); - $this->write(Formatter::formatRun($item, $this->format)); - $this->currentTestName = null; + $this->write($result->info->identity->pipelineId, Formatter::formatRun($item, $this->format)); + unset($this->currentTestName[$result->info->identity->pipelineId]); } /** @@ -466,7 +553,7 @@ private function printFailures(): void return; } - $this->write(Formatter::failuresHeader()); + $this->write(null, Formatter::failuresHeader()); $index = 1; foreach ($this->failures as $failure) { @@ -507,7 +594,7 @@ function: $result->info->testDefinition->reflection, ? "{$file}:{$line}" : null; - $this->write(Formatter::failureDetail( + $this->write(null, Formatter::failureDetail( $index, $testName, $message, @@ -527,14 +614,14 @@ private function printStatistics(RunResult $result): void { $summary = $result->summary; - $this->write(Formatter::summary($summary, $result->duration)); + $this->write(null, Formatter::summary($summary, $result->duration)); if ($summary->total() === 0) { - $this->write(Formatter::emptyBanner()); + $this->write(null, Formatter::emptyBanner()); return; } $failures = $summary->failed() + $summary->count(Status::Aborted); - $this->write(Formatter::finalBanner($failures === 0)); + $this->write(null, Formatter::finalBanner($failures === 0)); } } diff --git a/core/Output/Terminal/TerminalPlugin.php b/core/Output/Terminal/TerminalPlugin.php index 311abd5e..bfdb20a9 100644 --- a/core/Output/Terminal/TerminalPlugin.php +++ b/core/Output/Terminal/TerminalPlugin.php @@ -7,7 +7,6 @@ use Internal\Container\Container; use Testo\Common\EventListenerCollector; use Testo\Common\PluginConfigurator; -use Testo\Core\Context\TestInfo; use Testo\Event\Framework\SessionFinished; use Testo\Event\Framework\SessionStarting; use Testo\Event\Message\MessageReceived; @@ -16,7 +15,6 @@ use Testo\Event\Test\TestDataSetFinished; use Testo\Event\Test\TestDataSetStarting; use Testo\Event\Test\TestPipelineFinished; -use Testo\Event\Test\TestPipelineStarting; use Testo\Event\TestCase\TestCaseFinished; use Testo\Event\TestCase\TestCaseStarting; use Testo\Event\TestSuite\TestSuiteFinished; @@ -34,9 +32,12 @@ final class TerminalPlugin implements PluginConfigurator { /** - * Tracks whether we're inside a DataProvider batch. + * Ids of the tests currently inside a DataProvider batch. * - * @var array + * Keyed by {@see \Testo\Core\Context\Identity\TestIdentity::$pipelineId} so concurrently running + * tests are tracked independently. + * + * @var array */ private array $isBatch = []; @@ -61,7 +62,6 @@ public function configure(Container $container): void $listeners->addListener(MessageReceived::class, $this->onMessageReceived(...)); // Test Pipeline events (lifecycle of entire test through all interceptors) - $listeners->addListener(TestPipelineStarting::class, $this->onTestPipelineStarting(...)); $listeners->addListener(TestPipelineFinished::class, $this->onTestPipelineFinished(...)); // Test Batch events (for DataProvider) @@ -81,11 +81,6 @@ public function configure(Container $container): void $listeners->addListener(TestSuiteFinished::class, $this->onTestSuiteFinished(...)); } - private static function getId(TestInfo $testInfo): string - { - return \spl_object_hash($testInfo->testDefinition); - } - private function onSessionStarting(SessionStarting $event): void { $this->logger->ensureHeader(); @@ -99,22 +94,19 @@ private function onSessionFinished(SessionFinished $event): void private function onMessageReceived(MessageReceived $event): void { - $this->logger->logMessage($event->message); - } - - private function onTestPipelineStarting(TestPipelineStarting $event): void - { - // Fresh channel grouping for the test about to run. - $this->logger->resetChannels(); + // A null identity means the message belongs to no test (suite/case setup, output between + // tests); the logger writes that through instead of putting it in anyone's block. + $this->logger->logMessage($event->message, $event->identity?->pipelineId); } private function onTestPipelineFinished(TestPipelineFinished $event): void { // Check if this test was inside a DataProvider batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->pipelineId; if (isset($this->isBatch[$id])) { // DataProvider test - already handled in dataset events unset($this->isBatch[$id]); + $this->logger->closeTest($event->testInfo); return; } @@ -122,12 +114,15 @@ private function onTestPipelineFinished(TestPipelineFinished $event): void $this->logger->testStartedFromInfo($event->testInfo); $duration = (int) $event->testResult->getAttribute('duration'); $this->logger->handleTestResult($event->testResult, $duration); + + // Last write for this test is done, so its block can go out. + $this->logger->closeTest($event->testInfo); } private function onTestBatchStarting(TestBatchStarting $event): void { // Mark that we're inside a batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->pipelineId; $this->isBatch[$id] = true; // Start batch in logger for proper indentation @@ -142,8 +137,9 @@ private function onTestBatchFinished(TestBatchFinished $event): void private function onTestDataSetStarting(TestDataSetStarting $event): void { - // Fresh channel grouping for the data set about to run. - $this->logger->resetChannels(); + // Data sets share the batch's block, so nothing else would separate their channel output — + // reset the grouping so this one opens with a fresh header. + $this->logger->resetChannels($event->testInfo); // Log individual dataset start with custom name $prefix = $event->providerIndex === null ? '' : "$event->providerIndex:"; @@ -166,6 +162,9 @@ private function onTestCaseStarting(TestCaseStarting $event): void private function onTestCaseFinished(TestCaseFinished $event): void { $this->logger->handleCaseResult($event->caseInfo, $event->caseResult); + + // No test spans a case boundary: a leftover entry belongs to a test that never finished. + $this->isBatch = []; } private function onTestSuiteStarting(TestSuiteStarting $event): void diff --git a/core/Pipeline/Internal/OutputInterceptor.php b/core/Pipeline/Internal/OutputInterceptor.php index c3500897..9a293d07 100644 --- a/core/Pipeline/Internal/OutputInterceptor.php +++ b/core/Pipeline/Internal/OutputInterceptor.php @@ -45,7 +45,8 @@ public function __construct( #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { - # Fork the messenger: everything written during this test lands in an isolated buffer. + # Fork the messenger: everything written during this test lands in an isolated buffer, and + # every MessageReceived it dispatches is stamped with this test's identity. return $this->messenger->scope(static function (Messenger $messenger) use ($info, $next): TestResult { $result = $next($info); @@ -54,6 +55,6 @@ public function runTest(TestInfo $info, callable $next): TestResult return $messages->isEmpty() ? $result : $result->withMessages($messages); - }); + }, $info->identity); } } diff --git a/plugin/bench/src/Internal/Pipeline/BenchInterceptor.php b/plugin/bench/src/Internal/Pipeline/BenchInterceptor.php index 8be6f75b..763e287a 100644 --- a/plugin/bench/src/Internal/Pipeline/BenchInterceptor.php +++ b/plugin/bench/src/Internal/Pipeline/BenchInterceptor.php @@ -57,7 +57,11 @@ public function runTest(TestInfo $info, callable $next): TestResult $results = []; $status = Status::Passed; foreach ($attributes as $index => $attr) { - $newInfo = $info->with(arguments: $attr->arguments)->withAttribute(Bench::class, $attr); + # Each attribute occupies the provider slot of the address, as it does for inline tests, + # with a single data set inside it. + $newInfo = $info + ->with(arguments: $attr->arguments, identity: $info->identity->toDataSet(dataProvider: $index, dataSet: 0)) + ->withAttribute(Bench::class, $attr); $label = "$index"; # Dispatch dataset starting event diff --git a/plugin/codecov/src/Internal/Middleware/CoverageTestInterceptor.php b/plugin/codecov/src/Internal/Middleware/CoverageTestInterceptor.php index 12fe0b59..cd095e4c 100644 --- a/plugin/codecov/src/Internal/Middleware/CoverageTestInterceptor.php +++ b/plugin/codecov/src/Internal/Middleware/CoverageTestInterceptor.php @@ -78,46 +78,16 @@ public function runTest(TestInfo $info, callable $next): TestResult $coversTargets = \array_filter($attributes, static fn(CoverageAttribute $a): bool => $a instanceof Covers); $coversTargets === [] or $coverage = CoverageFilter::apply($coverage, \array_values($coversTargets)); - $method = self::buildMethodId($info); - $method === null or $coverage = $coverage->withTestMethod($method); + # The address names the concrete case class, not the method's declaring class, so a `#[Test]` + # inherited from an abstract base is attributed to the subclass — which is what keeps + # `` aligned with the JUnit `` that + # Infection joins on. Data sets deliberately share one identifier: `qualifiedName()` carries no + # coordinates, and per-data-set granularity would break that join rather than sharpen it. + $coverage = $coverage->withTestMethod($info->identity->qualifiedName()); return $result->withAttribute(CoverageResult::class, $coverage); } - /** - * Builds a PHPUnit-style identifier for the test method. - * - * - Class methods: `Tests\\FooTest::testBar`. - * - Free functions: `Tests\\testFooBar` (FQN, no leading backslash). - * - * For inherited tests the concrete (runtime) case class is used, NOT the - * declaring class: a `#[Test]` method declared on an abstract base and run - * through a subclass is attributed to the subclass. This keeps the coverage - * `` aligned with the JUnit ``, which Infection joins on by class name — `getDeclaringClass()` - * would name the abstract base, which has no testsuite, and the lookup would fail. - * - * Data-set entries within data providers reuse the same identifier — Testo's - * `--filter` selects by method, not by individual dataset, so per-dataset granularity - * isn't useful for downstream consumers (e.g. Infection). - * - * @return non-empty-string|null - */ - private static function buildMethodId(TestInfo $info): ?string - { - $reflection = $info->testDefinition->reflection; - - if ($reflection instanceof \ReflectionMethod) { - $class = $info->caseInfo->definition->reflection; - $className = $class?->getName() ?? $reflection->getDeclaringClass()->getName(); - $name = $className . '::' . $reflection->getName(); - return $name === '' ? null : $name; - } - - $name = $reflection->getName(); - return $name === '' ? null : $name; - } - /** * Collects all coverage attributes from the method/function and class hierarchy. * diff --git a/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php b/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php index b16c73af..219c1a7b 100644 --- a/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php +++ b/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php @@ -4,11 +4,13 @@ namespace Tests\Codecov\Unit\Internal; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Internal\CoverageCollector; use Testo\Codecov\Report\CoverageReport; use Testo\Codecov\Result\CoverageResult; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\CaseResult; use Testo\Core\Context\SuiteResult; use Testo\Core\Context\TestInfo; @@ -89,7 +91,7 @@ public function generate(CoverageResult $result): void private static function createTestResult(): TestResult { $reflection = new \ReflectionMethod(self::class, 'createTestResult'); - $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test')); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Codecov/Unit'), definition: new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__))); $testDefinition = new TestDefinition(reflection: $reflection); $info = new TestInfo(name: 'testMethod', caseInfo: $caseInfo, testDefinition: $testDefinition); diff --git a/plugin/codecov/tests/Unit/Middleware/CoverageTestInterceptorTest.php b/plugin/codecov/tests/Unit/Middleware/CoverageTestInterceptorTest.php index 7f307cd8..ee0f2268 100644 --- a/plugin/codecov/tests/Unit/Middleware/CoverageTestInterceptorTest.php +++ b/plugin/codecov/tests/Unit/Middleware/CoverageTestInterceptorTest.php @@ -4,10 +4,12 @@ namespace Tests\Codecov\Unit\Middleware; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Internal\Middleware\CoverageTestInterceptor; use Testo\Codecov\Result\CoverageResult; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -254,11 +256,13 @@ private static function makeTestInfo(string $class, string $method, string $type $reflection = new \ReflectionMethod($class, $method); return new TestInfo( - name: "{$class}::{$method}", + name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Codecov/Unit'), definition: new CaseDefinition( name: $class, type: $type, + file: Path::create(__FILE__), reflection: new \ReflectionClass($class), ), ), diff --git a/plugin/codecov/tests/Unit/Middleware/CoversFilteringTest.php b/plugin/codecov/tests/Unit/Middleware/CoversFilteringTest.php index 8fd706c3..80fe9860 100644 --- a/plugin/codecov/tests/Unit/Middleware/CoversFilteringTest.php +++ b/plugin/codecov/tests/Unit/Middleware/CoversFilteringTest.php @@ -4,6 +4,7 @@ namespace Tests\Codecov\Unit\Middleware; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Internal\Middleware\CoverageTestInterceptor; use Testo\Codecov\Result\CoverageResult; @@ -11,6 +12,7 @@ use Testo\Codecov\Result\LineCoverage; use Testo\Codecov\Result\LineStatus; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -153,11 +155,13 @@ private static function makeTestInfo(string $class, string $method): TestInfo $reflection = new \ReflectionMethod($class, $method); return new TestInfo( - name: "{$class}::{$method}", + name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Codecov/Unit'), definition: new CaseDefinition( name: $class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass($class), ), ), diff --git a/plugin/data/src/Internal/DataProviderInterceptor.php b/plugin/data/src/Internal/DataProviderInterceptor.php index 274addde..8214cb1e 100644 --- a/plugin/data/src/Internal/DataProviderInterceptor.php +++ b/plugin/data/src/Internal/DataProviderInterceptor.php @@ -5,6 +5,7 @@ namespace Testo\Data\Internal; use Psr\EventDispatcher\EventDispatcherInterface; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; @@ -94,7 +95,17 @@ public function runTest(TestInfo $info, callable $next): TestResult // } - $result = $this->run($info, $next, $label, \count($attributes) === 1 ? null : $pNum, $num, $dataset); + $result = $this->run( + $info, + $next, + $label, + # Events name the provider only when there is more than one to tell apart; the + # address always carries the real index. + \count($attributes) === 1 ? null : $pNum, + $num, + $dataset, + $info->identity->toDataSet(dataProvider: $pNum, dataSet: $num), + ); $result->status->isFailure() and $status = Status::Failed; $results[] = $result; } @@ -147,17 +158,20 @@ public function runTest(TestInfo $info, callable $next): TestResult * @param int<0, max>|null $providerNum Data provider number or null if only one. * @param int<0, max> $datasetNum Data set number. * @param callable(TestInfo): TestResult $next Next interceptor or core logic to run the test. + * @param TestIdentity $identity Address of this data set, derived from the batch's. */ - public function run( + private function run( TestInfo $info, callable $next, string $label, ?int $providerNum, int $datasetNum, array $arguments, + TestIdentity $identity, ): TestResult { $newInfo = $info->with( arguments: $arguments, + identity: $identity, ); // Dispatch dataset starting event diff --git a/plugin/data/tests/Unit/Internal/DataProviderInterceptorTest.php b/plugin/data/tests/Unit/Internal/DataProviderInterceptorTest.php index 1d568a42..ebd2e96b 100644 --- a/plugin/data/tests/Unit/Internal/DataProviderInterceptorTest.php +++ b/plugin/data/tests/Unit/Internal/DataProviderInterceptorTest.php @@ -4,10 +4,12 @@ namespace Tests\Data\Unit\Internal; +use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -70,8 +72,8 @@ public function dispatch(object $event): object private static function createTestInfo(): TestInfo { $reflection = new \ReflectionMethod(MultiProviderTarget::class, 'target'); - $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); - $caseInfo = new CaseInfo(definition: $caseDefinition); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Data/Unit'), definition: $caseDefinition); $testDefinition = new TestDefinition(reflection: $reflection); return new TestInfo( diff --git a/plugin/filter/tests/Unit/Internal/DataPointerTest.php b/plugin/filter/tests/Unit/Internal/DataPointerTest.php index 9bc731ea..43315c0e 100644 --- a/plugin/filter/tests/Unit/Internal/DataPointerTest.php +++ b/plugin/filter/tests/Unit/Internal/DataPointerTest.php @@ -4,9 +4,11 @@ namespace Tests\Filter\Unit\Internal; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinitions; @@ -111,9 +113,11 @@ private function makeTestInfo(TestDefinition $test): TestInfo return new TestInfo( name: $reflection->getName(), caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Filter/Unit'), definition: new \Testo\Core\Definition\CaseDefinition( name: $reflection->getDeclaringClass()->getName(), type: 'test', + file: Path::create(__FILE__), reflection: $reflection->getDeclaringClass(), ), ), diff --git a/plugin/inline/src/Internal/InlineInterceptor.php b/plugin/inline/src/Internal/InlineInterceptor.php index 5101e58f..d378452d 100644 --- a/plugin/inline/src/Internal/InlineInterceptor.php +++ b/plugin/inline/src/Internal/InlineInterceptor.php @@ -67,7 +67,11 @@ public function runTest(TestInfo $info, callable $next): TestResult continue; } - $newInfo = $info->with(arguments: $inline->arguments)->withAttribute(TestInline::class, $inline); + # Each attribute occupies the provider slot of the address, matching the filter check above + # (`--filter=method:2` selects the third one), with a single data set inside it. + $newInfo = $info + ->with(arguments: $inline->arguments, identity: $info->identity->toDataSet(dataProvider: $index, dataSet: 0)) + ->withAttribute(TestInline::class, $inline); $label = "$index"; # Dispatch dataset starting event diff --git a/plugin/inline/tests/Unit/InlineInterceptorTest.php b/plugin/inline/tests/Unit/InlineInterceptorTest.php index ad63d8da..66d5845d 100644 --- a/plugin/inline/tests/Unit/InlineInterceptorTest.php +++ b/plugin/inline/tests/Unit/InlineInterceptorTest.php @@ -4,10 +4,12 @@ namespace Tests\Inline\Unit; +use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -90,7 +92,10 @@ public function dispatch(object $event): object */ private static function createTestInfo(array $inlines, ?DataPointer $dataPointer = null): TestInfo { - $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test')); + $caseInfo = new CaseInfo( + definition: new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)), + suiteIdentity: new SuiteIdentity('Inline/Unit') + ); $testDefinition = new TestDefinition(reflection: new \ReflectionFunction(static fn() => null)); $attributes = [TestInline::class => $inlines]; diff --git a/plugin/repeat/tests/Unit/RepeatInterceptorTest.php b/plugin/repeat/tests/Unit/RepeatInterceptorTest.php index 59b63778..e6189ebe 100644 --- a/plugin/repeat/tests/Unit/RepeatInterceptorTest.php +++ b/plugin/repeat/tests/Unit/RepeatInterceptorTest.php @@ -4,10 +4,12 @@ namespace Tests\Repeat\Unit; +use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Assert; use Testo\Common\Messenger; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -311,8 +313,8 @@ public function dispatch(object $event): object private static function createTestInfo(): TestInfo { $reflection = new \ReflectionMethod(self::class, 'createTestInfo'); - $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); - $caseInfo = new CaseInfo(definition: $caseDefinition); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Repeat/Unit'), definition: $caseDefinition); $testDefinition = new TestDefinition(reflection: $reflection); return new TestInfo( diff --git a/plugin/retry/tests/Unit/RetryPolicyRunInterceptorTest.php b/plugin/retry/tests/Unit/RetryPolicyRunInterceptorTest.php index 42fe896f..934b9c89 100644 --- a/plugin/retry/tests/Unit/RetryPolicyRunInterceptorTest.php +++ b/plugin/retry/tests/Unit/RetryPolicyRunInterceptorTest.php @@ -4,11 +4,13 @@ namespace Tests\Retry\Unit; +use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Common\Messenger; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -263,8 +265,8 @@ public function dispatch(object $event): object private static function createTestInfo(): TestInfo { $reflection = new \ReflectionMethod(self::class, 'createTestInfo'); - $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); - $caseInfo = new CaseInfo(definition: $caseDefinition); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Retry/Unit'), definition: $caseDefinition); $testDefinition = new TestDefinition(reflection: $reflection); return new TestInfo( diff --git a/skills/testo-fiber/SKILL.md b/skills/testo-fiber/SKILL.md index 50f9d760..39c11c4c 100644 --- a/skills/testo-fiber/SKILL.md +++ b/skills/testo-fiber/SKILL.md @@ -57,6 +57,7 @@ final class RaceTest - `Schedule` enum (`Testo\Fiber\Schedule`): `Solo` (each test in its own fiber, to completion, no interleave — default), `RoundRobin` (one step per ready test each round), `Random` (a random ready test each round — non-seeded, not reproducible yet). - `RoundRobin` / `Random` interleave the case's tests on plain fibers, switching only where a fiber calls `\Fiber::suspend()`. Put a `\Fiber::suspend()` where a context switch should be allowed (in real use, the async driver the test exercises does this). Per-test assertion state stays isolated across the interleave. +- Reports stay readable while tests interleave: each test carries a `TestIdentity`, so the terminal renders every test — its batch node, data sets, streamed `-vv` output and result line — as one contiguous block instead of splicing them together, and `--teamcity` stamps a per-test `flowId`. Blocks appear in the order tests finish, so a test that is not the one currently streaming shows up once it completes. ## Pitfalls diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index 19d44c68..3318fd05 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -95,15 +95,66 @@ final readonly class MyInterceptor implements TestRunInterceptor { … } ```php TestInfo { string $name; CaseInfo $caseInfo; TestDefinition $testDefinition; - array $arguments; array $attributes; } // testDefinition->reflection: ReflectionFunctionAbstract -CaseInfo { CaseDefinition $definition; ?CaseInstance $instance; array $attributes; } - // definition->reflection: ?ReflectionClass + array $arguments; array $attributes; TestIdentity $identity; } + // testDefinition->reflection: ReflectionFunctionAbstract +CaseInfo { CaseDefinition $definition; ?CaseInstance $instance; array $attributes; + CaseIdentity $identity; } // definition->reflection: ?ReflectionClass +SuiteInfo { string $name; CaseDefinitions $testCases; array $attributes; SuiteIdentity $identity; } TestResult{ TestInfo $info; Status $status; mixed $result; ?\Throwable $failure; … } ``` Read the test method via `$info->testDefinition->reflection`; the test class via `$info->caseInfo->definition->reflection` (null for function-based cases — guard it). +### Identity — where a test is, and which run of it + +Every context object carries its address. `abstract readonly class Identity` in `Testo\Core\Context` +contributes `runtimeId`, `?parentId` and `fqn()`; each level declares exactly the fields it has, and lives in +`Testo\Core\Context\Identity\*` — `SuiteIdentity { suite }`, `CaseIdentity { suite, ?case, type, file }`, +`TestIdentity { suite, ?case, type, file, test, ?dataProvider, ?dataSet, pipelineId }`. No level references the one +above it, so reading any part of an address never walks a chain of objects, and no level carries a field +that does not apply to it. + +`case` is the **class FQN**, and `null` for a case of free functions — there is no class, so `file` +(an `Internal\Path`) names the case instead. `test` is the name **relative to `case`**: a bare method +name when there is a class, the function's own FQN when there is not. + +Step down with `SuiteIdentity::toCase($case, $type, $file)`, `CaseIdentity::toTest($test)`, and +`TestIdentity::toDataSet(dataProvider:, dataSet:)` for a data set. + +```php +$info->identity->fqn(); // 'Tests\Foo\BarTest::itWorks:0:1' — null only at the case level, + // 'Tests\Foo\freeTest' for a function (namespace, no class) +$info->identity->suite; // 'Core/Unit' +$info->identity->runtimeId; // this run; a data set has its own +$info->identity->pipelineId; // the test run it belongs to; a data set answers its batch's +$info->identity->parentId; // the run it opened inside: suite ← case ← test ← data set +``` + +Two independent things live on it: + +- **The address** (`fqn()` and the fields) says *which* test this is and is stable from + run to run. `dataProvider`/`dataSet` are set only for a data set, and address it by **index** — + provider keys may repeat, so only the index tells two data sets apart. `fqn()` is the machine-facing + form: no suite, no type, pastes straight into `--filter`, and is the tail of TeamCity's + `locationHint` (`php_qn://::\`). +- **`runtimeId`** says *which run of it* is in flight, **`pipelineId`** which test run that one is part + of — its own for a test, the batch's for each of its data sets — and **`parentId`** which run it + opened inside (`null` at a suite). All three are process-local: never persist them or match on them. + Repeats and retries keep `runtimeId`, since they re-attempt one run. + +Key per-test state in a reporter by one of the two rather than by a single "current test" field: when +tests run concurrently (fibers, an event loop) their events and output interleave, and a scalar gets +clobbered. Key by **`pipelineId`** for anything a whole test owns — a report block, a channel grouping, +a TeamCity flow — or a batch would break into as many pieces as it has data sets; by **`runtimeId`** +only for state that is genuinely per data set. `MessageReceived` carries the identity of the test (or +data set) that emitted it as `?TestIdentity $identity` (`null` when the message belongs to no test). + +To report a **tree** rather than a stream — an IDE that wants `nodeId`/`parentNodeId` — take the node +from `runtimeId` and its parent from `parentId`, the same two fields at every level. Do not read the +tree off the order events arrive in: concurrent tests interleave, so a consumer that nests by "whatever +opened last" puts one test's node inside another's. + ### Passing state down the pipeline — prefer attributes over mutable fields `TestInfo`, `CaseInfo`, and `TestResult` use the `Attributed` trait: `withAttribute(string $name, diff --git a/tests/Application/Unit/Messenger/MessengerHubTest.php b/tests/Application/Unit/Messenger/MessengerHubTest.php index 89e0a8c5..2d05f397 100644 --- a/tests/Application/Unit/Messenger/MessengerHubTest.php +++ b/tests/Application/Unit/Messenger/MessengerHubTest.php @@ -4,10 +4,13 @@ namespace Tests\Application\Unit\Messenger; +use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Application\Internal\MessengerHub; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Core\Context\Identity\SuiteIdentity; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; use Testo\Event\Message\MessageReceived; @@ -178,6 +181,61 @@ public function forkIsFiberSafeAcrossSuspension(): void Assert::same($contents, ['after', 'before', 'root', 'while-suspended']); } + public function aNestedScopeInheritsTheEnclosingTestIdentity(): void + { + $dispatcher = new class implements EventDispatcherInterface { + /** @var list */ + public array $identities = []; + + public function dispatch(object $event): object + { + $event instanceof MessageReceived and $this->identities[] = $event->identity; + return $event; + } + }; + $hub = new MessengerHub($dispatcher); + $identity = self::identity(); + + $hub->scope(static function () use ($hub): void { + // A plugin isolating output mid-test opens a scope of its own; what is written inside + // still originates from the running test, so it must stay attributed to that test. + $hub->scope(static fn() => $hub->log('c', 'inner')); + }, $identity); + + Assert::same($dispatcher->identities, [$identity]); + } + + public function aNestedScopeMayStillSetAnIdentityOfItsOwn(): void + { + $dispatcher = new class implements EventDispatcherInterface { + /** @var list */ + public array $identities = []; + + public function dispatch(object $event): object + { + $event instanceof MessageReceived and $this->identities[] = $event->identity; + return $event; + } + }; + $hub = new MessengerHub($dispatcher); + $outer = self::identity(); + $inner = $outer->toDataSet(dataProvider: 0, dataSet: 1); + + $hub->scope(static function () use ($hub, $inner): void { + $hub->scope(static fn() => $hub->log('c', 'inner'), $inner); + }, $outer); + + // Inheritance is only the default: the per-data-set scope narrows the batch's address. + Assert::same($dispatcher->identities, [$inner]); + } + + private static function identity(): TestIdentity + { + return (new SuiteIdentity('Application/Unit')) + ->toCase('Tests\Foo\BarTest', 'test', Path::create('/app/tests/BarTest.php')) + ->toTest('itWorks'); + } + private function nullDispatcher(): EventDispatcherInterface { return new class implements EventDispatcherInterface { diff --git a/tests/Core/Context/IdentityTest.php b/tests/Core/Context/IdentityTest.php new file mode 100644 index 00000000..f0d4648d --- /dev/null +++ b/tests/Core/Context/IdentityTest.php @@ -0,0 +1,250 @@ +suite, 'Core/Unit'); + Assert::same($test->case, 'Tests\Foo\BarTest'); + Assert::same($test->type, 'test'); + Assert::same($test->test, 'itWorks'); + Assert::same((string) $test->file, '/app/tests/BarTest.php'); + Assert::null($test->dataProvider); + Assert::null($test->dataSet); + } + + public function aDataSetIsAddressedByIndex(): void + { + $test = self::test(); + + // Provider keys repeat freely (`yield 1 => …` twice is legal), so only the indices tell two + // data sets of one test apart. + Assert::same($test->toDataSet(dataProvider: 0, dataSet: 1)->fqn(), 'Tests\Foo\BarTest::itWorks:0:1'); + Assert::same($test->toDataSet(dataProvider: 2, dataSet: 0)->fqn(), 'Tests\Foo\BarTest::itWorks:2:0'); + } + + public function aReDerivedAddressCarriesNoStaleRendering(): void + { + $test = self::test(); + + // The strings are composed once, in the constructor, so `toDataSet()` has to rebuild rather than + // copy — otherwise a re-derived address would still render the coordinates it no longer has. + $moved = $test->toDataSet(dataProvider: 0, dataSet: 1)->toDataSet(dataProvider: 2, dataSet: 3); + + Assert::same($moved->fqn(), 'Tests\Foo\BarTest::itWorks:2:3'); + + // Overriding one coordinate keeps the other, and still re-renders. + Assert::same($test->toDataSet(dataProvider: 0, dataSet: 1)->toDataSet(dataSet: 7)->fqn(), 'Tests\Foo\BarTest::itWorks:0:7'); + } + + public function theFqnNamesCodeAndNothingElse(): void + { + $case = (new SuiteIdentity('Core/Unit'))->toCase('Tests\Foo\BarTest', 'test', self::path()); + $test = $case->toTest('itWorks'); + + // The same class runs under any suite and any type, and both are filtered separately, so + // neither belongs in the form that `--filter` and TeamCity consume. Nor does the file. + Assert::same($case->fqn(), 'Tests\Foo\BarTest'); + Assert::same($test->fqn(), 'Tests\Foo\BarTest::itWorks'); + Assert::same($test->toDataSet(dataProvider: 0, dataSet: 1)->fqn(), 'Tests\Foo\BarTest::itWorks:0:1'); + } + + public function theQualifiedNameDropsTheCoordinatesTheFqnKeeps(): void + { + $dataSet = self::test()->toDataSet(dataProvider: 1, dataSet: 4); + + // Consumers that group by test method — coverage entries, the JUnit `classname` — need every + // data set of one test to answer the same string, so they read this rather than `fqn()`. + Assert::same($dataSet->qualifiedName(), 'Tests\Foo\BarTest::itWorks'); + Assert::same($dataSet->fqn(), 'Tests\Foo\BarTest::itWorks:1:4'); + } + + public function aTestNameIsRelativeToItsCaseAndAbsoluteWithoutOne(): void + { + $withClass = self::test(); + $free = (new SuiteIdentity('Core/Unit')) + ->toCase(null, 'test', self::path('/app/tests/functions.php')) + ->toTest('Tests\Foo\itWorksToo'); + + // A method is named relative to its class, so the bare name is complete. A free function has no + // class to be relative to, so it carries its own namespace in the same field — which is also + // why "a class *and* a namespace" is not a state this type can be put in. + Assert::same($withClass->fqn(), 'Tests\Foo\BarTest::itWorks'); + Assert::same($free->fqn(), 'Tests\Foo\itWorksToo'); + + // The case itself still has no FQN — a file is not one. + Assert::null($free->case); + } + + public function aCaseWithoutAClassIsNamedByItsFile(): void + { + $case = (new SuiteIdentity('Core/Unit'))->toCase(null, 'test', self::path('/app/tests/functions.php')); + + // No class means no FQN; the file is the only thing that tells two such cases of one suite apart. + Assert::null($case->fqn()); + Assert::same((string) $case->file, '/app/tests/functions.php'); + } + + #[Covers(CaseInfo::class)] + public function theFileComesStraightFromTheDefinition(): void + { + $definition = new CaseDefinition( + name: 'CaseInfo', + type: 'test', + file: Path::create((new \ReflectionClass(CaseInfo::class))->getFileName()), + reflection: new \ReflectionClass(CaseInfo::class), + ); + + $case = new CaseInfo($definition, new SuiteIdentity('Core/Context')); + + // One spelling for the whole address, and it is `Path` that guarantees it: a filename reflection + // reports with the OS separator comes back normalized, so the field cannot name one file two ways. + Assert::same($case->identity->file, $definition->file); + Assert::same(\substr_count((string) $case->identity->file, '\\'), 0); + } + + public function theFileStaysOnTheAddressEvenWithAClass(): void + { + $case = (new SuiteIdentity('Core/Unit'))->toCase('Tests\Foo\BarTest', 'test', self::path()); + + // A class does name its own file, but resolving that means loading the class — and TeamCity's + // location hint needs both parts side by side. + Assert::same((string) $case->file, '/app/tests/BarTest.php'); + Assert::same((string) $case->toTest('itWorks')->file, '/app/tests/BarTest.php'); + + // The class still wins for the FQN. + Assert::same($case->fqn(), 'Tests\Foo\BarTest'); + } + + public function theSuiteFqnIsItsName(): void + { + $suite = new SuiteIdentity('Core/Unit'); + + // A suite is a configuration entry rather than code: its name is all there is to give, and it + // is exactly what `--suite` matches. + Assert::same($suite->fqn(), 'Core/Unit'); + } + + public function distinctRunsGetDistinctRuntimeIds(): void + { + $case = (new SuiteIdentity('Core/Unit'))->toCase('Tests\Foo\BarTest', 'test', self::path()); + + $first = $case->toTest('itWorks'); + $second = $case->toTest('itWorks'); + + // Same address, different runs — which is exactly why the address alone cannot serve as the + // correlation key for output and events. + Assert::same($first->fqn(), $second->fqn()); + Assert::notSame($first->runtimeId, $second->runtimeId); + } + + public function eachDataSetIsARunOfItsOwn(): void + { + $test = self::test(); + + $first = $test->toDataSet(dataProvider: 0, dataSet: 0); + $second = $test->toDataSet(dataProvider: 0, dataSet: 1); + + // A data set runs, and so it correlates its own events and its own captured output. + Assert::same(\count(\array_unique([$test->runtimeId, $first->runtimeId, $second->runtimeId])), 3); + } + + public function everyDataSetBelongsToTheTestRunItCameFrom(): void + { + $test = self::test(); + + $first = $test->toDataSet(dataProvider: 0, dataSet: 0); + $second = $test->toDataSet(dataProvider: 1, dataSet: 0)->toDataSet(dataSet: 3); + + // What holds a batch together in a report — one terminal block, one TeamCity flow. It survives + // re-deriving, so a data set addressed in two steps is still inside the same test run. + Assert::same($first->pipelineId, $test->pipelineId); + Assert::same($second->pipelineId, $test->pipelineId); + + // A test opens its own run, so grouping by this number needs no special case for a test + // that never had a data set. + Assert::same($test->pipelineId, $test->runtimeId); + } + + public function everyLevelPointsAtTheRunItOpenedInside(): void + { + $suite = new SuiteIdentity('Core/Unit'); + $case = $suite->toCase('Tests\Foo\BarTest', 'test', self::path()); + $test = $case->toTest('itWorks'); + $dataSet = $test->toDataSet(dataProvider: 0, dataSet: 1); + + // One field at every level, so rebuilding the tree of a run — an IDE's `parentNodeId` — needs + // neither the order the events arrived in nor knowledge of which level is in hand. + Assert::null($suite->parentId); + Assert::same($case->parentId, $suite->runtimeId); + Assert::same($test->parentId, $case->runtimeId); + Assert::same($dataSet->parentId, $test->runtimeId); + } + + public function reDerivingADataSetLeavesItASiblingOfTheOthers(): void + { + $test = self::test(); + + // Deriving from a data set corrects coordinates rather than nesting a data set inside one, so + // the test stays the parent of both. + Assert::same($test->toDataSet(dataProvider: 0, dataSet: 1)->toDataSet(dataSet: 7)->parentId, $test->runtimeId); + } + + public function everyLevelGetsItsOwnRuntimeId(): void + { + $suite = new SuiteIdentity('Core/Unit'); + $case = $suite->toCase('Tests\Foo\BarTest', 'test', self::path()); + $test = $case->toTest('itWorks'); + + // One shared sequence behind all three levels, so a suite and a case never collide on a number. + Assert::same(\count(\array_unique([$suite->runtimeId, $case->runtimeId, $test->runtimeId])), 3); + } + + public function halfADataSetAddressIsRejected(): void + { + Expect::exception(\InvalidArgumentException::class); + + // Without both coordinates the address would claim to be a data set it cannot name. + new TestIdentity('Core/Unit', 'Tests\Foo\BarTest', 'test', self::path(), 'itWorks', dataProvider: 0); + } + + private static function test(): TestIdentity + { + return (new SuiteIdentity('Core/Unit')) + ->toCase('Tests\Foo\BarTest', 'test', self::path()) + ->toTest('itWorks'); + } + + private static function path(string $path = '/app/tests/BarTest.php'): Path + { + return Path::create($path); + } +} diff --git a/tests/Core/Pipeline/AttributesInterceptorTest.php b/tests/Core/Pipeline/AttributesInterceptorTest.php index 4072abcc..1bd61323 100644 --- a/tests/Core/Pipeline/AttributesInterceptorTest.php +++ b/tests/Core/Pipeline/AttributesInterceptorTest.php @@ -4,10 +4,12 @@ namespace Tests\Core\Pipeline; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -367,8 +369,9 @@ private function makeCaseInfo(?\ReflectionClass $reflection): CaseInfo return new CaseInfo(new CaseDefinition( name: 'TestCase', type: 'unit', + file: Path::create(__FILE__), reflection: $reflection, - )); + ), new SuiteIdentity('Core/Pipeline')); } private function createInterceptorProvider(): InterceptorProvider diff --git a/tests/Core/Pipeline/OutputInterceptorTest.php b/tests/Core/Pipeline/OutputInterceptorTest.php index a395d2c2..f3c1d101 100644 --- a/tests/Core/Pipeline/OutputInterceptorTest.php +++ b/tests/Core/Pipeline/OutputInterceptorTest.php @@ -4,10 +4,13 @@ namespace Tests\Core\Pipeline; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Common\Messenger; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -31,7 +34,7 @@ public function runTestCallsNextWithInfoAndReturnsResult(): void $testResult = new TestResult($testInfo, Status::Passed); $received = null; - $result = $interceptor->runTest($testInfo, function(TestInfo $info) use (&$received, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function (TestInfo $info) use (&$received, $testResult): TestResult { $received = $info; return $testResult; }); @@ -47,7 +50,7 @@ public function runTestReturnsOriginalResultWhenMessagesEmpty(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, fn() => $testResult); + $result = $interceptor->runTest($testInfo, static fn() => $testResult); Assert::same($result, $testResult); Assert::true($result->messages->isEmpty()); @@ -60,7 +63,7 @@ public function runTestAttachesMessagesToResultWhenMessagesPresent(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -86,7 +89,7 @@ public function runTestIsolatesPreScopeMessagesFromResult(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -107,7 +110,7 @@ public function runTestPreservesResultStatusWhenAttachingMessages(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Failed, failure: new \Exception('Test failed')); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -126,9 +129,10 @@ private function makeTestInfo(): TestInfo $caseDefinition = new CaseDefinition( name: 'TestCase', type: 'unit', + file: Path::create(__FILE__), reflection: null, ); - $caseInfo = new CaseInfo($caseDefinition); + $caseInfo = new CaseInfo($caseDefinition, new SuiteIdentity('Core/Pipeline')); $testDefinition = new TestDefinition( reflection: new \ReflectionMethod(SimpleTest::class, 'test'), ); @@ -167,7 +171,7 @@ public function channel(string $name): Messenger\Channel * empty child buffer for the duration of the callback so {@see getMessages()} inside observes * only what was written within the scope, then restores the parent and discards the child. */ - public function scope(\Closure $scope): mixed + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed { $parent = $this->messages; $this->messages = []; diff --git a/tests/Core/suites.php b/tests/Core/suites.php index 4fe70855..a43a304e 100644 --- a/tests/Core/suites.php +++ b/tests/Core/suites.php @@ -6,6 +6,12 @@ use Testo\Application\Config\SuiteConfig; return [ + new SuiteConfig( + name: 'Core/Context', + location: new FinderConfig( + include: [__DIR__ . '/Context'], + ), + ), new SuiteConfig( name: 'Core/Log', location: new FinderConfig( diff --git a/tests/Output/Unit/JUnit/JUnitPluginTest.php b/tests/Output/Unit/JUnit/JUnitPluginTest.php index e0454c01..3873f714 100644 --- a/tests/Output/Unit/JUnit/JUnitPluginTest.php +++ b/tests/Output/Unit/JUnit/JUnitPluginTest.php @@ -4,6 +4,7 @@ namespace Tests\Output\Unit\JUnit; +use Internal\Path; use Internal\Container\ObjectContainer; use Testo\Application\Internal\EventDispatcher; use Testo\Assert; @@ -13,6 +14,7 @@ use Testo\Core\Context\RunResult; use Testo\Core\Context\SuiteInfo; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -138,6 +140,53 @@ public function dataProviderEmitsCasePerDataSetAndSuppressesPipelineCase(): void } } + public function interleavedBatchesSharingADefinitionDoNotClobberEachOther(): void + { + $path = self::tmpPath(); + try { + $dispatcher = self::wirePlugin(new JUnitPlugin($path)); + + $suiteInfo = self::makeSuiteInfo('CoreSuite'); + $caseInfo = self::makeCaseInfo(); + + // Two runs of one test method: distinct in-flight tests, one shared TestDefinition object. + // The batch guard is keyed per running test, so one pipeline's finish must not clear the + // other's marker. + $definition = new TestDefinition(new \ReflectionMethod(SampleTestClass::class, 'passingTest')); + $first = new TestInfo(name: 'passingTest', caseInfo: $caseInfo, testDefinition: $definition); + $second = new TestInfo(name: 'passingTest', caseInfo: $caseInfo, testDefinition: $definition); + + $firstResult = new TestResult(info: $first, status: Status::Passed); + $secondResult = new TestResult(info: $second, status: Status::Passed); + + $dispatcher->dispatch(new SessionStarting()); + $dispatcher->dispatch(new TestSuiteStarting($suiteInfo)); + $dispatcher->dispatch(new TestCaseStarting($caseInfo)); + $dispatcher->dispatch(new TestBatchStarting($first)); + $dispatcher->dispatch(new TestBatchStarting($second)); + $dispatcher->dispatch(new TestDataSetFinished($first, $firstResult, 'alpha', null, 0)); + $dispatcher->dispatch(new TestDataSetFinished($second, $secondResult, 'beta', null, 0)); + $dispatcher->dispatch(new TestBatchFinished($first, $firstResult)); + $dispatcher->dispatch(new TestBatchFinished($second, $secondResult)); + $dispatcher->dispatch(new TestPipelineFinished($first, $firstResult)); + $dispatcher->dispatch(new TestPipelineFinished($second, $secondResult)); + $dispatcher->dispatch(new TestCaseFinished($caseInfo, new CaseResult([$firstResult], Status::Passed))); + $dispatcher->dispatch(new TestSuiteFinished($suiteInfo, new SuiteResult([], Status::Passed))); + $dispatcher->dispatch(self::sessionFinished()); + + // One per data set and no rolled-up duplicate for either batch. + $xml = \simplexml_load_file($path); + Assert::notSame($xml, false); + Assert::same((string) $xml['tests'], '2'); + $cases = $xml->testsuite->testsuite->testcase; + Assert::count($cases, 2); + Assert::same((string) $cases[0]['name'], 'passingTest [alpha]'); + Assert::same((string) $cases[1]['name'], 'passingTest [beta]'); + } finally { + self::cleanup($path); + } + } + public function multipleProvidersIncludeProviderIndexInName(): void { // Arrange @@ -526,9 +575,11 @@ private static function makeSuiteInfo(string $name): SuiteInfo private static function makeCaseInfo(): CaseInfo { return new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ); @@ -551,9 +602,11 @@ private static function makeFreeFunctionCaseInfo(): CaseInfo // No class reflection — emulates how Testo builds a case for a file // containing free-function tests (see CaseDefinitions::define). return new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: 'free_function_helper.php', type: 'test', + file: Path::create(__FILE__), reflection: null, ), ); @@ -564,10 +617,14 @@ private static function makeFreeFunctionCaseInfo(): CaseInfo */ private static function makeFreeFunctionTestInfo(CaseInfo $caseInfo, string $functionFqn): TestInfo { + $reflection = new \ReflectionFunction($functionFqn); + + # Discovery keys a test by its short name and the runner passes that key straight through, so a + # name that disagrees with the reflection is a shape the runtime never produces. return new TestInfo( - name: 'free', + name: $reflection->getShortName(), caseInfo: $caseInfo, - testDefinition: new TestDefinition(new \ReflectionFunction($functionFqn)), + testDefinition: new TestDefinition($reflection), ); } diff --git a/tests/Output/Unit/JUnit/JUnitWriterTest.php b/tests/Output/Unit/JUnit/JUnitWriterTest.php index 49c6866c..996d5127 100644 --- a/tests/Output/Unit/JUnit/JUnitWriterTest.php +++ b/tests/Output/Unit/JUnit/JUnitWriterTest.php @@ -4,8 +4,10 @@ namespace Tests\Output\Unit\JUnit; +use Internal\Path; use Testo\Assert; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -458,9 +460,11 @@ private static function makeResult( $info = new TestInfo( name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ), @@ -485,9 +489,11 @@ private static function makeInheritedResult(Status $status): TestResult $info = new TestInfo( name: 'inheritedTest', caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: ConcreteSampleTest::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(ConcreteSampleTest::class), ), ), @@ -506,11 +512,13 @@ private static function makeResultWithReflection( Status $status, ): TestResult { $info = new TestInfo( - name: 'free', + name: $reflection->getShortName(), caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: null, type: 'test', + file: Path::create(__FILE__), reflection: null, ), ), diff --git a/tests/Output/Unit/Json/JsonPluginTest.php b/tests/Output/Unit/Json/JsonPluginTest.php index 025a5e99..cd3b99e5 100644 --- a/tests/Output/Unit/Json/JsonPluginTest.php +++ b/tests/Output/Unit/Json/JsonPluginTest.php @@ -4,6 +4,7 @@ namespace Tests\Output\Unit\Json; +use Internal\Path; use Internal\Container\ObjectContainer; use Testo\Application\Internal\EventDispatcher; use Testo\Assert; @@ -13,6 +14,7 @@ use Testo\Core\Context\CaseResult; use Testo\Core\Context\RunResult; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -89,9 +91,11 @@ private static function failedRun(): RunResult $info = new TestInfo( name: 'failingTest', caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ), diff --git a/tests/Output/Unit/Json/JsonReportTest.php b/tests/Output/Unit/Json/JsonReportTest.php index 1f4766b1..a64a2ffb 100644 --- a/tests/Output/Unit/Json/JsonReportTest.php +++ b/tests/Output/Unit/Json/JsonReportTest.php @@ -4,12 +4,14 @@ namespace Tests\Output\Unit\Json; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; use Testo\Core\Context\RunResult; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -159,7 +161,8 @@ public function freeFunctionTestUsesFunctionFqn(): void $info = new TestInfo( name: 'free', caseInfo: new CaseInfo( - definition: new CaseDefinition(name: null, type: 'test', reflection: null), + suiteIdentity: new SuiteIdentity('Output/Unit'), + definition: new CaseDefinition(name: null, type: 'test', file: Path::create(__FILE__), reflection: null), ), testDefinition: new TestDefinition($reflection), ); @@ -229,9 +232,11 @@ private static function test( $info = new TestInfo( name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ), diff --git a/tests/Output/Unit/Rendering/ChannelRendererTest.php b/tests/Output/Unit/Rendering/ChannelRendererTest.php index c1137a50..5da021c8 100644 --- a/tests/Output/Unit/Rendering/ChannelRendererTest.php +++ b/tests/Output/Unit/Rendering/ChannelRendererTest.php @@ -9,6 +9,8 @@ use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Output\Rendering\ChannelRenderer; +use Testo\Output\Rendering\Color; +use Testo\Output\Terminal\Renderer\Style; use Testo\Test; #[Test] @@ -75,6 +77,19 @@ public function resetMakesSameChannelEmitHeaderAgain(): void Assert::string($out)->notContains("\n\n"); } + public function resetAssumesTheCallerLeftTheCursorAtLineStart(): void + { + $renderer = new ChannelRenderer(); + $renderer->render(self::message('stdout', 'no-newline')); + + $renderer->reset(); + $out = self::stripAnsi($renderer->render(self::message('stdout', "again\n"))); + + // The renderer sees only its own content, and reset() marks a boundary the caller terminated + // itself (a test's result line) — so it must not insert a separator of its own. + Assert::true(\str_starts_with($out, '[stdout] '), "Unwanted separator after reset: {$out}"); + } + public function headerFormatsTimeAsWallClockWithMilliseconds(): void { $renderer = new ChannelRenderer(); @@ -101,18 +116,53 @@ public function formatTimeClampsMillisecondsToNineNineNine(): void ); } - public function headerColorIsNameDerivedFromTheFullEightColorPalette(): void + public function aChannelAlwaysGetsTheSameHeaderColor(): void { - $renderer = new ChannelRenderer(); + // Derived from the name, so it survives across renderers — that is what lets a reader track one + // channel down a report. Which color any given name lands on is crc32 arithmetic and pinning it + // would freeze the palette's order against a reordering that changes nothing. + Assert::same(self::headerColor('stderr'), self::headerColor('stderr')); + Assert::same(self::headerColor('sql'), self::headerColor('sql')); + } - // abs(crc32('stderr')) % 8 == 1 -> Color::Cyan ("\033[36m"). Dropping Color::Cyan from the - // palette (size 8 -> 7) shifts the mapping and would colorize this header green instead. - $out = $renderer->render(self::message('stderr', "oops\n")); + public function everyColorOfThePaletteIsReachable(): void + { + $seen = []; + for ($i = 0; $i < 200; ++$i) { + $seen[self::headerColor("channel-{$i}")] = true; + } + + // The size claim, stated as a set so it holds whatever order the palette sits in: drop a color + // and this shrinks. Black stays out on purpose — invisible on a dark terminal. + Assert::same(\count($seen), 8); + Assert::false(isset($seen[Color::Black->value])); + } - Assert::true( - \str_starts_with($out, "\033[36m[stderr]\033[0m "), - "Header not Cyan-colorized for 'stderr': " . \addcslashes($out, "\033"), - ); + /** + * The color a channel's header is rendered in, as its raw escape sequence. + * + * Colorization hangs on a process-global flag in {@see Style}, and building a `TerminalPlugin` sets + * it — so a case elsewhere in the run can leave it off and there would be no color here to read. + * Turning it on for the render and putting back what was there keeps this independent of whatever + * order the finder walks files in, which differs between platforms. + * + * @param non-empty-string $channel + */ + private static function headerColor(string $channel): string + { + $colors = Style::areColorsEnabled(); + Style::setColorsEnabled(true); + + try { + $out = (new ChannelRenderer())->render(self::message($channel, "x\n")); + } finally { + Style::setColorsEnabled($colors); + } + + \preg_match('/^\033\[\d+m/', $out, $matches); + Assert::notSame($matches, [], "Header not colorized for '{$channel}': " . \addcslashes($out, "\033")); + + return $matches[0]; } private static function stripAnsi(string $text): string diff --git a/tests/Output/Unit/Rendering/SharedStreamTest.php b/tests/Output/Unit/Rendering/SharedStreamTest.php new file mode 100644 index 00000000..e7bbdf4d --- /dev/null +++ b/tests/Output/Unit/Rendering/SharedStreamTest.php @@ -0,0 +1,170 @@ +write(1, "a1\n"); + $stream->write(2, "b1\n"); + $stream->write(1, "a2\n"); + }); + + // Test 1 took the lease with its first write, so only its lines reach the stream; test 2 is + // held back rather than cutting through the middle of test 1's block. + Assert::same($out, "a1\na2\n"); + } + + public function releasingTheLeaseLetsTheFinishedBlocksOutWhole(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->write(2, "b1\n"); + $stream->write(2, "b2\n"); + $stream->close(2); + $stream->write(1, "a2\n"); + $stream->close(1); + }); + + // Test 2 finished while test 1 held the lease, so its whole block lands the moment test 1 + // releases — after test 1's output, never spliced into it. + Assert::same($out, "a1\na2\nb1\nb2\n"); + } + + public function blocksAreDrainedInTheOrderTheyFinished(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a\n"); + $stream->write(2, "b\n"); + $stream->write(3, "c\n"); + $stream->close(3); + $stream->close(2); + $stream->close(1); + }); + + Assert::same($out, "a\nc\nb\n"); + } + + public function aWaitingWriterTakesTheLeaseAndKeepsItsBlockInOnePiece(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->write(2, "b1\n"); + $stream->close(1); + $stream->write(2, "b2\n"); + }); + + // Test 2 buffered "b1" while test 1 was live; on taking the lease it must flush that first and + // then continue live, so the two halves of its block stay adjacent. + Assert::same($out, "a1\nb1\nb2\n"); + } + + public function aBlockNeverStartsHalfwayThroughALine(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, 'a-no-newline'); + $stream->write(2, "b\n"); + $stream->close(2); + $stream->close(1); + }); + + // Test 1 left the cursor mid-line, so test 2's block has to break it rather than glue itself + // onto the tail of a foreign line. + Assert::same($out, "a-no-newline\nb\n"); + } + + public function writingAfterCloseOpensAFreshBlock(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->write(2, "b1\n"); + $stream->write(3, "c1\n"); + $stream->close(2); + $stream->write(2, "b-late\n"); + $stream->close(3); + $stream->close(2); + $stream->close(1); + }); + + // The late write opens a second block for test 2 that queues on its own terms — had it + // reopened the closed one, "b-late" would have come out attached to "b1", ahead of "c1". + Assert::same($out, "a1\nb1\nc1\nb-late\n"); + } + + public function outputOwnedByNoTestGoesStraightThrough(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->write(null, "framework\n"); + $stream->write(1, "a2\n"); + }); + + Assert::same($out, "a1\nframework\na2\n"); + } + + public function flushDrainsBlocksOfTestsThatNeverFinished(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->write(2, "b-hung\n"); + $stream->flush(); + }); + + // Test 2 never closed — a hang or an abort. Its output is printed rather than dropped. + Assert::same($out, "a1\nb-hung\n"); + } + + public function closingATestThatWroteNothingEmitsNothing(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, "a1\n"); + $stream->close(2); + $stream->close(1); + }); + + Assert::same($out, "a1\n"); + } + + public function emptyWritesAreIgnoredAndDoNotTakeTheLease(): void + { + $out = self::capture(static function (SharedStream $stream): void { + $stream->write(1, ''); + $stream->write(2, "b1\n"); + $stream->write(1, "a1\n"); + }); + + // Test 1's empty write must not claim the stream, or test 2 would be buffered behind a test + // that has nothing to say. + Assert::same($out, "b1\n"); + } + + /** + * @param \Closure(SharedStream): void $scenario + */ + private static function capture(\Closure $scenario): string + { + $handle = \fopen('php://memory', 'rb+'); + \assert($handle !== false); + + try { + $scenario(new SharedStream($handle)); + \rewind($handle); + $out = \stream_get_contents($handle); + } finally { + \fclose($handle); + } + + return $out === false ? '' : $out; + } +} diff --git a/tests/Output/Unit/Teamcity/FormatterTest.php b/tests/Output/Unit/Teamcity/FormatterTest.php index 2bccbd6c..3967022f 100644 --- a/tests/Output/Unit/Teamcity/FormatterTest.php +++ b/tests/Output/Unit/Teamcity/FormatterTest.php @@ -4,13 +4,71 @@ namespace Tests\Output\Unit\Teamcity; +use Internal\Path; use Testo\Assert; +use Testo\Core\Context\Identity\SuiteIdentity; +use Testo\Core\Context\Identity\TestIdentity; use Testo\Output\Teamcity\Teamcity\Formatter; use Testo\Test; #[Test] final class FormatterTest { + public function aCaseHangsUnderItsSuiteRatherThanUnderWhateverOpenedLast(): void + { + $suite = new SuiteIdentity('Core/Unit'); + $case = $suite->toCase('Tests\Foo\BarTest', 'test', Path::create('/app/tests/BarTest.php')); + + $msg = Formatter::suiteStarted('BarTest', $case); + + // The tree is stated, not implied by the order messages arrive in — which is the only thing that + // survives concurrency, where two nodes are open at once. + Assert::string($msg)->contains("nodeId='{$case->runtimeId}'"); + Assert::string($msg)->contains("parentNodeId='{$suite->runtimeId}'"); + } + + public function aSuiteOfTheRunHangsUnderTheRoot(): void + { + $suite = new SuiteIdentity('Core/Unit'); + + $msg = Formatter::suiteStarted('Core/Unit', $suite); + + // Nothing sits above a suite, and the id-based protocol fixes the root node at 0. + Assert::string($msg)->contains("nodeId='{$suite->runtimeId}'"); + Assert::string($msg)->contains("parentNodeId='0'"); + + // A suite is a configuration entry rather than code, so there is nothing to point an editor at; + // and nothing else runs alongside it, so it needs no flow of its own. + Assert::string($msg)->notContains('locationHint'); + Assert::string($msg)->notContains('flowId'); + } + + public function aDataSetNamesTheBatchItCameFrom(): void + { + $test = self::test(); + $dataSet = $test->toDataSet(dataProvider: 0, dataSet: 1); + + $msg = Formatter::testStarted('Dataset #0:1 [x]', identity: $dataSet); + + // Its own node, under the batch's — and in the batch's flow, so the row lands inside the nested + // suite the batch opened. + Assert::string($msg)->contains("nodeId='{$dataSet->runtimeId}'"); + Assert::string($msg)->contains("parentNodeId='{$test->runtimeId}'"); + Assert::string($msg)->contains("flowId='{$test->pipelineId}'"); + } + + public function aFinishedMessageNamesTheNodeItCloses(): void + { + $test = self::test(); + + $msg = Formatter::testFinished('itWorks', 12, $test); + + // A consumer closes the node by id, so a finish never lands on the wrong one when two tests are + // open at once. + Assert::string($msg)->contains("nodeId='{$test->runtimeId}'"); + Assert::string($msg)->contains("duration='12'"); + } + public function testFailedWithoutComparisonHasNoExtraAttributes(): void { $msg = Formatter::testFailed( @@ -68,4 +126,11 @@ public function testFailedEscapesSpecialCharactersInExpectedAndActual(): void Assert::string($msg)->contains("expected='line1|nline2'"); Assert::string($msg)->contains("actual='|[item||with|'quote|]'"); } + + private static function test(): TestIdentity + { + return (new SuiteIdentity('Core/Unit')) + ->toCase('Tests\Foo\BarTest', 'test', Path::create('/app/tests/BarTest.php')) + ->toTest('itWorks'); + } } diff --git a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php index 1b488f1b..5364e182 100644 --- a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php +++ b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php @@ -4,14 +4,18 @@ namespace Tests\Output\Unit\Teamcity; +use Internal\Path; use Testo\Assert; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Assertion\ComparisonFailure; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Definition\TestDefinition; +use Testo\Core\Log\Level; +use Testo\Core\Log\Message; use Testo\Core\Value\Status; use Testo\Output\Teamcity\Teamcity\TeamcityLogger; use Testo\Test; @@ -81,9 +85,11 @@ public function testStartedFromInfoAttributesInheritedTestToConcreteCaseClass(): $info = new TestInfo( name: 'inheritedTest', caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: ConcreteSampleTestCase::class, type: 'test', + file: Path::create(__FILE__), reflection: $caseReflection, ), ), @@ -98,6 +104,32 @@ public function testStartedFromInfoAttributesInheritedTestToConcreteCaseClass(): Assert::string($output)->notContains('AbstractSampleTestCase::inheritedTest'); } + public function testStartedFromInfoAddressesADataSetByItsCoordinates(): void + { + $batch = self::makeInfo('passingTest'); + $first = $batch->with(identity: $batch->identity->toDataSet(dataProvider: 0, dataSet: 1)); + $second = $batch->with(identity: $batch->identity->toDataSet(dataProvider: 0, dataSet: 2)); + + $a = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($first)); + $b = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($second)); + + // The coordinates `--filter` takes, in the order it takes them. A key-based hint would collide + // whenever a provider repeats one — these two would come out identical and select nothing when + // pasted back. + Assert::string($a)->contains('SampleTestClass::passingTest:0:1'); + Assert::string($b)->contains('SampleTestClass::passingTest:0:2'); + } + + public function testStartedFromInfoLeavesABatchNodeWithoutCoordinates(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($info)); + + // A test that is not a data set carries no tail at all, so the hint stays clickable as a method. + Assert::string($output)->contains("SampleTestClass::passingTest'"); + } + public function testStartedFromInfoEmitsDescriptionFromPhpDoc(): void { $info = self::makeInfo('describedTest'); @@ -124,6 +156,70 @@ public function logEmptyRunEmitsBuildProblem(): void Assert::string($output)->contains("description='No tests were executed'"); } + public function testStartedFromInfoStampsFlowIdFromIdentity(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($info)); + + Assert::string($output)->contains("flowId='{$info->identity->pipelineId}'"); + } + + public function aDataSetReportsInsideItsBatchesFlow(): void + { + $batch = self::makeInfo('passingTest'); + $dataSet = $batch->with(identity: $batch->identity->toDataSet(dataProvider: 0, dataSet: 1)); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($dataSet)); + + // The batch opened a nested suite in its own flow, and a data set reports inside that suite — + // a flow of its own (its run differs from the batch's) would leave the suite behind. + Assert::notSame($dataSet->identity->runtimeId, $batch->identity->runtimeId); + Assert::string($output)->contains("flowId='{$batch->identity->pipelineId}'"); + } + + public function handleSingleTestResultStampsFlowIdFromIdentity(): void + { + $result = self::makeFailedResult(new \RuntimeException('boom')); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSingleTestResult($result)); + + // Both the failure and the finish message carry the test's flow, so a consumer keeps them together. + Assert::same(\substr_count($output, "flowId='{$result->info->identity->pipelineId}'"), 2); + } + + public function logMessagePlacesTheOutputOnItsTestsNode(): void + { + $info = self::makeInfo('passingTest'); + $message = new Message(time: 0.0, channel: 'stdout', level: Level::Info, content: 'streamed line'); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->logMessage('someTest', $message, $info->identity), + ); + + // Streamed output has to name the node it came from, or a consumer attaches it to whichever test + // happens to be open — which, when tests interleave, is somebody else's. + Assert::string($output)->contains("nodeId='{$info->identity->runtimeId}'"); + Assert::string($output)->contains("flowId='{$info->identity->pipelineId}'"); + Assert::string($output)->contains('streamed line'); + } + + public function distinctTestsGetDistinctFlowIds(): void + { + $first = self::makeInfo('passingTest'); + $second = self::makeInfo('passingTest'); + + // Two separate runs of the same method are distinct in-flight tests — their flows must differ so + // that, when interleaved, a consumer never merges their messages. + Assert::notSame($first->identity->pipelineId, $second->identity->pipelineId); + + $a = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($first)); + $b = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($second)); + + Assert::string($a)->contains("flowId='{$first->identity->pipelineId}'"); + Assert::string($b)->contains("flowId='{$second->identity->pipelineId}'"); + } + /** * Runs the callback against a logger writing to an in-memory stream and returns what it wrote. * @@ -178,9 +274,11 @@ private static function makeInfo(string $method): TestInfo return new TestInfo( name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ), diff --git a/tests/Output/Unit/Terminal/FormatterTest.php b/tests/Output/Unit/Terminal/FormatterTest.php index e1866b98..9e360398 100644 --- a/tests/Output/Unit/Terminal/FormatterTest.php +++ b/tests/Output/Unit/Terminal/FormatterTest.php @@ -8,7 +8,6 @@ use Testo\Assert\State\Assertion\ComparisonFailure; use Testo\Core\Value\Status; use Testo\Output\Terminal\Renderer\Formatter; -use Testo\Output\Terminal\Renderer\Style; use Testo\Test; #[Test] @@ -103,17 +102,6 @@ public function emptyBannerReadsNoTests(): void Assert::string(Formatter::emptyBanner())->contains('NO TESTS'); } - 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. diff --git a/tests/Output/Unit/Terminal/TerminalLoggerTest.php b/tests/Output/Unit/Terminal/TerminalLoggerTest.php index 2681eeec..b631714d 100644 --- a/tests/Output/Unit/Terminal/TerminalLoggerTest.php +++ b/tests/Output/Unit/Terminal/TerminalLoggerTest.php @@ -4,12 +4,14 @@ namespace Tests\Output\Unit\Terminal; +use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; use Testo\Core\Context\RunResult; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -22,7 +24,6 @@ use Testo\Core\Value\Summary; use Testo\Core\Value\Verbosity; use Testo\Output\Terminal\Renderer\OutputFormat; -use Testo\Output\Terminal\Renderer\Style; use Testo\Output\Terminal\Renderer\TerminalLogger; use Testo\Test; use Tests\Output\Stub\JUnit\SampleTestClass; @@ -132,7 +133,14 @@ public function dataProviderDescriptionIsPrintedOnceAtTheBatchNode(): void // once under the batch node and never repeat under the individual datasets. $description = 'Sample description line.'; $first = self::test('describedTest', Status::Passed, attributes: ['description' => $description]); - $second = self::test('describedTest', Status::Passed, attributes: ['description' => $description]); + // Data sets of one batch are derived with TestInfo::with(), so they share the batch's info — + // and its identity, which is what the logger keys the batch indentation by. + $second = self::test( + 'describedTest', + Status::Passed, + attributes: ['description' => $description], + info: $first->info, + ); $output = self::renderBatch($first->info, [ 'Dataset #0 [0]' => $first, @@ -154,15 +162,80 @@ public function regularTestStillPrintsItsDescription(): void Assert::string($output)->contains($description); } - protected function setUp(): void + public function interleavedBatchesEachReportUnderTheirOwnDataSetName(): void + { + $first = self::test('passingTest', Status::Passed); + $second = self::test('failingTest', Status::Passed); + + $output = self::capture(static function (TerminalLogger $logger) use ($first, $second): void { + $logger->batchStartedFromInfo($first->info); + $logger->batchStartedFromInfo($second->info); + $logger->testStartedFromInfo($first->info, 'Dataset #0 [a]'); + $logger->testStartedFromInfo($second->info, 'Dataset #0 [b]'); + // Out of order on purpose: the second batch's data set reports first. + $logger->handleTestResult($second, 0); + $logger->handleTestResult($first, 0); + $logger->closeTest($first->info); + $logger->closeTest($second->info); + }); + + // A single "current test" slot would give both data sets the name of whichever batch announced + // its data set last, and lose the other one entirely. + Assert::string($output)->contains('Dataset #0 [a]'); + Assert::string($output)->contains('Dataset #0 [b]'); + } + + public function aTestIsNotIndentedByAnInterleavedBatch(): void + { + $batch = self::test('passingTest', Status::Passed); + $regular = self::test('failingTest', Status::Passed); + + $alone = self::capture(static fn(TerminalLogger $logger) => $logger->handleTestResult($regular, 0)); + $besideBatch = self::capture(static function (TerminalLogger $logger) use ($batch, $regular): void { + $logger->batchStartedFromInfo($batch->info); + $logger->handleTestResult($regular, 0); + $logger->closeTest($batch->info); + $logger->closeTest($regular->info); + }); + + // The batch's indentation belongs to the batch. A test finishing while someone else's batch is + // open must render exactly as it would on its own — compared line-for-line, since the indented + // form merely *contains* the unindented one. + Assert::same(self::lastLine($besideBatch), self::lastLine($alone)); + } + + /** + * Last non-empty line of the rendered output, for comparing one report line exactly. + */ + private static function lastLine(string $output): string { - // Strip ANSI styling so assertions match raw text regardless of TTY config. - Style::setColorsEnabled(false); + $lines = \array_filter(\explode("\n", $output), static fn(string $line): bool => $line !== ''); + \assert($lines !== []); + + return (string) \end($lines); } - protected function tearDown(): void + /** + * Drives the callback against a logger writing to an in-memory stream and returns what it wrote. + * For scenarios whose event order matters — {@see render()} and {@see renderBatch()} cover the + * fixed sequential ones. + * + * @param \Closure(TerminalLogger): void $scenario + */ + private static function capture(\Closure $scenario): string { - Style::setColorsEnabled(true); + $stream = \fopen('php://memory', 'rb+'); + \assert($stream !== false); + + try { + $scenario(new TerminalLogger(OutputFormat::Compact, Verbosity::Normal, $stream)); + \rewind($stream); + $output = \stream_get_contents($stream); + } finally { + \fclose($stream); + } + + return $output === false ? '' : $output; } /** @@ -247,13 +320,16 @@ private static function test( ?\Throwable $failure = null, ?MessageLog $messages = null, array $attributes = [], + ?TestInfo $info = null, ): TestResult { - $info = new TestInfo( + $info ??= new TestInfo( name: $method, caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), definition: new CaseDefinition( name: SampleTestClass::class, type: 'test', + file: Path::create(__FILE__), reflection: new \ReflectionClass(SampleTestClass::class), ), ), diff --git a/tests/Output/Unit/Terminal/TerminalPluginTest.php b/tests/Output/Unit/Terminal/TerminalPluginTest.php new file mode 100644 index 00000000..8eb44d38 --- /dev/null +++ b/tests/Output/Unit/Terminal/TerminalPluginTest.php @@ -0,0 +1,208 @@ +dispatch(new TestBatchStarting($first)); + $dispatcher->dispatch(new TestBatchStarting($second)); + self::dataSet($dispatcher, $first, 'a0'); + self::dataSet($dispatcher, $second, 'b0'); + $dispatcher->dispatch(new TestBatchFinished($first, self::passed($first))); + $dispatcher->dispatch(new TestPipelineFinished($first, self::passed($first))); + $dispatcher->dispatch(new TestBatchFinished($second, self::passed($second))); + $dispatcher->dispatch(new TestPipelineFinished($second, self::passed($second))); + }); + + // Both batch headers with both data sets under whichever came last would be unreadable — each + // tree has to come out whole: header, then its own data set. + Assert::same( + \array_values(\array_filter(\array_map(\trim(...), \explode("\n", $output)))), + [ + '◆ passingTest', + '✓ Dataset #0 [a0] (0ms)', + '◆ failingTest', + '✓ Dataset #0 [b0] (0ms)', + ], + ); + } + + public function aTestsStreamedOutputStaysInOnePieceWhileAnotherRuns(): void + { + $first = self::makeTestInfo('passingTest'); + $second = self::makeTestInfo('failingTest'); + + $output = self::capture(static function (EventDispatcher $dispatcher) use ($first, $second): void { + $dispatcher->dispatch(self::message("a1\n", $first)); + $dispatcher->dispatch(self::message("b1\n", $second)); + $dispatcher->dispatch(self::message("a2\n", $first)); + $dispatcher->dispatch(new TestPipelineFinished($first, self::passed($first))); + $dispatcher->dispatch(self::message("b2\n", $second)); + $dispatcher->dispatch(new TestPipelineFinished($second, self::passed($second))); + }); + + // The first test took the stream, so everything of its own — output and result line — precedes + // anything of the second, whose lines then follow adjacent to each other. + $at = static fn(string $needle): int => (int) \strpos($output, $needle); + Assert::true($at('a1') < $at('a2'), $output); + Assert::true($at('a2') < $at('passingTest'), $output); + Assert::true($at('passingTest') < $at('b1'), $output); + Assert::true($at('b1') < $at('b2'), $output); + } + + public function sequentialTestsAreUnaffected(): void + { + $first = self::makeTestInfo('passingTest'); + $second = self::makeTestInfo('failingTest'); + + $output = self::capture(static function (EventDispatcher $dispatcher) use ($first, $second): void { + $dispatcher->dispatch(self::message("a1\n", $first)); + $dispatcher->dispatch(new TestPipelineFinished($first, self::passed($first))); + $dispatcher->dispatch(self::message("b1\n", $second)); + $dispatcher->dispatch(new TestPipelineFinished($second, self::passed($second))); + }); + + // One test at a time never waits, and each still opens its own channel header. + Assert::same(\substr_count($output, '[stdout] '), 2); + Assert::true((int) \strpos($output, 'a1') < (int) \strpos($output, 'b1')); + } + + public function messageOwnedByNoTestIsStillStreamed(): void + { + $output = self::capture(static function (EventDispatcher $dispatcher): void { + $dispatcher->dispatch(new MessageReceived( + new Message(0.0, 'stdout', Level::Info, "between tests\n"), + )); + }); + + // Output that belongs to no test (suite/case setup) has no block to join, but dropping it + // would lose framework-level output the user asked to see. + Assert::string($output)->contains("between tests\n"); + } + + /** + * Runs the callback against a plugin wired to a real dispatcher and returns what it wrote to the + * terminal stream. Verbose, because that is the verbosity at which output streams live. + * + * @param \Closure(EventDispatcher): void $scenario + */ + private static function capture(\Closure $scenario): string + { + $stdout = \fopen('php://memory', 'w+'); + $stderr = \fopen('php://memory', 'w+'); + \assert($stdout !== false && $stderr !== false); + + # Constructing the plugin writes colorization into a process-global flag, so this has to be put + # back: leaking `Never` would strip ANSI from every case that runs after this one. + $colors = Style::areColorsEnabled(); + + try { + $logger = new TerminalLogger(OutputFormat::Compact, Verbosity::Verbose, $stdout, $stderr); + $dispatcher = new EventDispatcher(); + $container = new ObjectContainer(); + $container->set($dispatcher, EventListenerCollector::class); + (new TerminalPlugin($logger, ColorMode::Never))->configure($container); + + $scenario($dispatcher); + + \rewind($stdout); + return (string) \stream_get_contents($stdout); + } finally { + Style::setColorsEnabled($colors); + \fclose($stdout); + \fclose($stderr); + } + } + + /** + * Runs one data set of `$info`'s batch, start to finish. + * + * Addressed under the batch, as the data-provider interceptor builds it — a data set is a run of + * its own, so it is only the batch's `pipelineId` that puts its lines in the batch's block. + */ + private static function dataSet(EventDispatcher $dispatcher, TestInfo $info, string $key): void + { + $set = $info->with(identity: $info->identity->toDataSet(dataProvider: 0, dataSet: 0)); + + $dispatcher->dispatch(new TestDataSetStarting($set, $key, null, 0)); + $dispatcher->dispatch(new TestDataSetFinished($set, self::passed($set), $key, null, 0)); + } + + private static function passed(TestInfo $info): TestResult + { + return new TestResult(info: $info, status: Status::Passed); + } + + private static function message(string $content, TestInfo $owner): MessageReceived + { + return new MessageReceived( + new Message(0.0, 'stdout', Level::Info, $content), + $owner->identity, + ); + } + + /** + * @param non-empty-string $method + */ + private static function makeTestInfo(string $method): TestInfo + { + return new TestInfo( + name: $method, + caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), + definition: new CaseDefinition( + name: SampleTestClass::class, + type: 'test', + file: Path::create(__FILE__), + reflection: new \ReflectionClass(SampleTestClass::class), + ), + ), + testDefinition: new TestDefinition(new \ReflectionMethod(SampleTestClass::class, $method)), + ); + } +} diff --git a/tests/Sandbox/Self/AssertTest.php b/tests/Sandbox/Self/AssertTest.php index 931edd3d..b482768f 100644 --- a/tests/Sandbox/Self/AssertTest.php +++ b/tests/Sandbox/Self/AssertTest.php @@ -12,6 +12,7 @@ use Testo\Data\DataProvider; use Testo\Data\DataSet; use Testo\Expect; +use Testo\Filter\Group; use Testo\Repeat; use Testo\Retry; use Testo\Test; @@ -21,6 +22,7 @@ /** * Aassertions sandbox */ +#[Group('sandbox')] final class AssertTest { #[Inject] diff --git a/tests/Sandbox/Self/AsyncTest.php b/tests/Sandbox/Self/AsyncTest.php new file mode 100644 index 00000000..35425d5a --- /dev/null +++ b/tests/Sandbox/Self/AsyncTest.php @@ -0,0 +1,113 @@ +, nap: int<0, max>}> + */ + private const PROFILES = [ + 'quick' => ['steps' => 3, 'nap' => 100_000], + 'steady' => ['steps' => 5, 'nap' => 150_000], + 'slow' => ['steps' => 8, 'nap' => 200_000], + ]; + + public function quickBursts(): void + { + self::workThenYield('quick'); + } + + public function steadyProgress(): void + { + self::workThenYield('steady'); + } + + public function slowGrind(): void + { + self::workThenYield('slow'); + } + + /** + * Data sets at a steady pace, interleaving with the ones below. + */ + #[DataSet(['slow-set-a'])] + #[DataSet(['slow-set-b'])] + public function slowDataSets(string $label): void + { + self::workThenYield('steady', $label); + } + + /** + * Data sets at a quick pace, interleaving with the ones above. + */ + #[DataSet(['fast-set-a'])] + #[DataSet(['fast-set-b'])] + #[DataSet(['fast-set-c'])] + #[DataSet(['fast-set-d'])] + #[DataSet(['fast-set-e'])] + #[DataSet(['fast-set-f'])] + #[DataSet(['fast-set-g'])] + #[DataSet(['fast-set-h'])] + #[DataSet(['fast-set-i'])] + #[DataSet(['fast-set-j'])] + #[DataSet(['fast-set-k'])] + #[DataSet(['fast-set-l'])] + #[DataSet(['fast-set-m'])] + #[DataSet(['fast-set-n'])] + public function fastDataSets(string $label): void + { + self::workThenYield('quick', $label); + } + + /** + * Do the `$label` profile's steps of "work" (a nap plus an echoed progress line), suspending the + * fiber after each one so the sibling tests take their step in between. + * + * @param non-empty-string $label Work profile to follow. + * @param non-empty-string|null $tag What to print the progress under, when it is not the profile + * itself — a data set naming itself rather than the pace it keeps. + */ + private static function workThenYield(string $label, ?string $tag = null): void + { + ['steps' => $steps, 'nap' => $nap] = self::PROFILES[$label]; + $tag ??= $label; + + $done = 0; + for ($i = 1; $i <= $steps; $i++) { + \usleep($nap); + ++$done; + echo \sprintf("[%s] step %d/%d (worked %.2fs)\n", $tag, $i, $steps, $nap / 1_000_000); + \Fiber::suspend(); + } + + Assert::same($done, $steps); + } +} diff --git a/tests/Sandbox/Self/DataCoordinatesTest.php b/tests/Sandbox/Self/DataCoordinatesTest.php new file mode 100644 index 00000000..5b2deb95 --- /dev/null +++ b/tests/Sandbox/Self/DataCoordinatesTest.php @@ -0,0 +1,55 @@ + ['provider-0 / data-set-0']; + yield 9 => ['provider-0 / data-set-1']; + yield 9 => ['provider-0 / data-set-2']; + } + + /** + * Provider #1. Two more, keyed `9` again — so the key repeats across providers as well. + */ + public static function second(): iterable + { + yield 9 => ['provider-1 / data-set-0']; + yield 9 => ['provider-1 / data-set-1']; + } + + #[DataProvider([self::class, 'first'])] + #[DataProvider([self::class, 'second'])] + public function coordinates(string $label): void + { + echo "reached {$label}\n"; + + Assert::true(\str_contains($label, 'data-set-'), 'the label names the coordinates it sits at'); + } +}