Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e6d6114
feat(core): give each test an identity and stamp it on MessageReceived
roxblnfk Jul 24, 2026
0cd062b
fix(teamcity): flowId per test so concurrent output stays attributable
roxblnfk Jul 24, 2026
c7c68fb
test(sandbox): add a cooperative-fiber sandbox that finishes staggered
roxblnfk Jul 26, 2026
d5c93cb
docs(skills): document TestIdentity for plugin authors
roxblnfk Jul 26, 2026
d79b028
fix(terminal): attribute streamed output per test identity
roxblnfk Jul 26, 2026
a8a0c51
fix(junit): key the DataProvider-batch guard by test identity
roxblnfk Jul 26, 2026
17e9011
refactor(terminal): key the logger's per-test state by identity
roxblnfk Jul 27, 2026
fd6064c
feat(output): add SharedStream, one stream shared by concurrent tests
roxblnfk Jul 27, 2026
768900d
fix(terminal): render each test's report lines as one block
roxblnfk Jul 27, 2026
6bffc08
refactor(output): drop ChannelRenderer's owner grouping and label
roxblnfk Jul 27, 2026
4d689ee
test(sandbox): interleave two data-provider batches in the async sandbox
roxblnfk Jul 27, 2026
395edeb
docs(skills): describe interleaved reporting as blocks, not labels
roxblnfk Jul 27, 2026
b320058
feat(core): make Identity a full test address
roxblnfk Jul 29, 2026
52cf6a5
feat(core): address tests by identity in the TeamCity, JUnit and cove…
roxblnfk Jul 30, 2026
89f4703
refactor(core): make an address impossible to build half-formed
roxblnfk Jul 31, 2026
5113f45
test(output): assert the channel palette by its size, not by crc32 ar…
roxblnfk Jul 31, 2026
71746b4
fix(tests): stop colorization leaking between output cases
roxblnfk Jul 31, 2026
c2a1df3
refactor(core): give a data set a run of its own, group reports by th…
roxblnfk Jul 31, 2026
ba7a544
feat(core): let an address name the run it opened inside
roxblnfk Jul 31, 2026
b0433dc
feat(output): state the TeamCity tree instead of implying it by order
roxblnfk Jul 31, 2026
1a86cb0
refactor(data): require the data-set address, close run() to callers
roxblnfk Jul 31, 2026
66369ef
fix(output): drop per-test reporter state at the case boundary
roxblnfk Jul 31, 2026
983f3c6
refactor(core): rename CaseIdentity::toTestIdentity() to toTest()
roxblnfk Jul 31, 2026
e8464dc
test(sandbox): give the fast data sets distinct labels
roxblnfk Jul 31, 2026
967cd84
fix(core): let a nested messenger scope inherit the test it opened in…
roxblnfk Jul 31, 2026
b15f2bf
refactor(core): drop the rendered form of an address
roxblnfk Aug 1, 2026
018fd55
docs(output): state invariants, not the history behind them
roxblnfk Aug 1, 2026
8b68da9
refactor(core): rename TestIdentity::with() to toDataSet()
roxblnfk Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions core/Application/Internal/Messenger/State.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
) {}

/**
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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;
}
Expand Down
9 changes: 7 additions & 2 deletions core/Application/Internal/MessengerHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions core/Application/Internal/Runner/SuiteRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
11 changes: 10 additions & 1 deletion core/Common/Messenger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 11 additions & 7 deletions core/Core/Context/CaseInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +21,7 @@
use Attributed;

public string $name;
public CaseIdentity $identity;

/**
* Handler for executing the test method.
Expand All @@ -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<non-empty-string, mixed> $attributes
* @param callable(TestInfo): mixed $handler Invoker for the test method.
Expand All @@ -36,25 +41,24 @@
*/
public function __construct(
public CaseDefinition $definition,
SuiteIdentity $suiteIdentity,
public ?CaseInstance $instance = null,
public array $attributes = [],
callable $handler = new DefaultTestHandler(),
public ?\Closure $batchRunner = null,
) {
$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);
}

/**
Expand Down
88 changes: 88 additions & 0 deletions core/Core/Context/Identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Context;

use Testo\Core\Internal\RuntimeSequence;

/**
* Address of one node in a run: a suite, a case, a test, or one data set of a test.
*
* Each level is its own type and declares exactly the fields it has — {@see Identity\SuiteIdentity},
* {@see Identity\CaseIdentity}, {@see Identity\TestIdentity} — so an address never carries a field
* that does not apply to it, and nothing has to be read as "absent because this level does not go
* that deep". The fields are plain scalars: no level references the one above it, so reading any part
* of an address never walks a chain of objects.
*
* Step down with {@see Identity\SuiteIdentity::toCase()}, {@see Identity\CaseIdentity::toTest()}
* and {@see Identity\TestIdentity::toDataSet()}.
*
* Two things live on an address, and they answer different questions:
* - the fields say *which* node this is, and stay the same across runs;
* - {@see $runtimeId} says *which run of it* is in flight, and means nothing outside this process.
* {@see $parentId} points at the run it opened inside, which is how a consumer rebuilds the tree of
* a run without relying on the order events happened to arrive in.
*
* @api
*/
abstract readonly class Identity
{
/**
* Number correlating everything this one in-flight run emits — its events, its captured output.
* Process-local and not part of the address: never persist it, never match on it, and expect a
* different number for the same test on the next run.
*
* One number per run, and a data set is a run: it gets its own rather than its batch's. What needs
* a whole test held together — a report block, a TeamCity flow — groups by
* {@see Identity\TestIdentity::$pipelineId} instead. Repeats and retries do share this number,
* since they re-attempt one run rather than open new ones.
*
* @var int<1, max>
*/
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;
}
62 changes: 62 additions & 0 deletions core/Core/Context/Identity/CaseIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Context\Identity;

use Internal\Path;
use Testo\Core\Context\Identity;

/**
* Address of a test case within its suite.
*
* @api
*/
final readonly class CaseIdentity extends Identity
{
/**
* @param non-empty-string $suite Suite name as configured in `testo.php`.
* @param non-empty-string|null $case Class FQN. Null for a case of free functions, which has no
* class — {@see $file} is what names such a case instead.
* @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. Carried even when there is a class — a
* class does name its own file, but resolving that means loading the class, and TeamCity
* wants both parts.
* @param int<1, max>|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;
}
}
50 changes: 50 additions & 0 deletions core/Core/Context/Identity/SuiteIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Context\Identity;

use Internal\Path;
use Testo\Core\Context\Identity;

/**
* Address of a test suite — where every other {@see Identity} starts.
*
* @api
*/
final readonly class SuiteIdentity extends Identity
{
/**
* @param non-empty-string $suite Suite name as configured in `testo.php`.
*/
public function __construct(
public string $suite,
) {
parent::__construct();
}

/**
* Step down to a case of this suite.
*
* @param non-empty-string|null $caseName Class FQN, or null for a case of free functions.
* {@see CaseIdentity::$case}.
* @param non-empty-string $type Case type — `test`, `inline`, `bench`, …
* {@see \Testo\Core\Value\TestType}. Part of the address because one file can define cases
* of several types.
* @param Path $file Path of the file the case was read from.
*/
public function toCase(?string $caseName, string $type, Path $file): CaseIdentity
{
return new CaseIdentity($this->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;
}
}
Loading
Loading