diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json
index 740af07..77d8757 100644
--- a/.github/.release-please-config.json
+++ b/.github/.release-please-config.json
@@ -112,6 +112,12 @@
"component": "bridge-rector",
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
+ },
+ "bridge/revolt": {
+ "package-name": "testo/bridge-revolt",
+ "component": "bridge-revolt",
+ "include-component-in-tag": true,
+ "changelog-path": "CHANGELOG.md"
}
},
"bump-patch-for-minor-pre-major": true,
diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml
index 914d4fc..49dc9ea 100644
--- a/.github/workflows/split-publish.yml
+++ b/.github/workflows/split-publish.yml
@@ -22,6 +22,7 @@ on: # yamllint disable-line rule:truthy
- 'bridge-infection-[0-9]*'
- 'bridge-mockery-[0-9]*'
- 'bridge-rector-[0-9]*'
+ - 'bridge-revolt-[0-9]*'
- 'bridge-symfony-console-[0-9]*'
- 'codecov-[0-9]*'
- 'convention-[0-9]*'
diff --git a/bridge/mockery/src/Internal/MockeryInterceptor.php b/bridge/mockery/src/Internal/MockeryInterceptor.php
index ff2a0d0..18ca1af 100644
--- a/bridge/mockery/src/Internal/MockeryInterceptor.php
+++ b/bridge/mockery/src/Internal/MockeryInterceptor.php
@@ -8,6 +8,7 @@
use Testo\Assert\Internal\StaticState;
use Testo\Assert\State\Expectation\ExpectationFailed;
use Testo\Assert\State\Expectation\ExpectationFulfilled;
+use Testo\Bridge\Mockery\MockeryConcurrencyException;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Core\Value\Status;
@@ -22,6 +23,14 @@
*
* Runs innermost so the teardown fires as close as possible to the test function.
*
+ * Mockery's mock container is process-global static state, so — unlike Testo's own fiber-local guards —
+ * it cannot be isolated per fiber. This guard therefore serializes: it runs the test directly (no fiber
+ * trampoline, so it cooperates with a real event loop) and, if another Mockery test is already in flight,
+ * refuses to start with a {@see MockeryConcurrencyException}. That only happens under interleaving
+ * ({@see \Testo\Fiber\Schedule::RoundRobin}/`Random`, {@see \Testo\Bridge\Revolt\Strategy::PerCase}),
+ * where siblings would clobber each other's mocks. One-at-a-time execution — `Solo`/`PerTest` or no
+ * fibers — is fully supported, including across a real event-loop suspension.
+ *
* @internal
* @psalm-internal Testo\Bridge\Mockery
*/
@@ -29,15 +38,26 @@
order: InterceptorOptions::ORDER_CLOSE_TO_TEST,
testType: TestType::Test,
)]
-final readonly class MockeryInterceptor implements TestRunInterceptor
+final class MockeryInterceptor implements TestRunInterceptor
{
+ /**
+ * Whether a Mockery-guarded test is currently running. The container is process-global, so a second
+ * test starting while this is set means two tests are interleaving over the same container.
+ */
+ private static bool $running = false;
+
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
+ self::$running and throw new MockeryConcurrencyException();
+ self::$running = true;
+
$result = null;
try {
- $result = $this->run($info, $next);
+ $result = $next($info);
} finally {
+ self::$running = false;
+
# close() clears the container, so read the count first.
$verified = \Mockery::getContainer()->mockery_getExpectationCount();
try {
@@ -100,42 +120,4 @@ private static function reportFailedExpectation(\Throwable $e): ?ExpectationFail
return $failure;
}
-
- /**
- * Run the test, keeping the global Mockery container bound to it across fiber suspensions.
- *
- * The container is process-global static state, so under concurrent (fiber-based) execution
- * sibling tests would clobber each other's mocks. On every suspension we save this test's
- * container and reset the global one; on resumption we restore it — the same scope-guarding
- * dance as {@see \Testo\Application\Internal\MessengerHub::scope()}.
- *
- * @param callable(TestInfo): TestResult $next
- */
- private function run(TestInfo $info, callable $next): TestResult
- {
- if (\Fiber::getCurrent() === null) {
- return $next($info);
- }
-
- $fiber = new \Fiber(static fn(): TestResult => $next($info));
- $value = $fiber->start();
- while (!$fiber->isTerminated()) {
- $container = \Mockery::getContainer();
- \Mockery::resetContainer();
- try {
- $resume = \Fiber::suspend($value);
- } catch (\Throwable $e) {
- \Mockery::setContainer($container);
- $value = $fiber->throw($e);
- continue;
- }
-
- \Mockery::setContainer($container);
- $value = $fiber->resume($resume);
- }
-
- /** @var TestResult $result */
- $result = $fiber->getReturn();
- return $result;
- }
}
diff --git a/bridge/mockery/src/MockeryConcurrencyException.php b/bridge/mockery/src/MockeryConcurrencyException.php
new file mode 100644
index 0000000..299d526
--- /dev/null
+++ b/bridge/mockery/src/MockeryConcurrencyException.php
@@ -0,0 +1,29 @@
+status, Status::Passed);
}
+ public function interleavedMockeryTestsAreRejected(): void
+ {
+ // Under Schedule::RoundRobin the two scenario tests interleave: the first parks mid-mock, so the
+ // second starts while the global container is still the first's. That second test must abort with
+ // a MockeryConcurrencyException rather than silently clobbering the first's mocks; the first,
+ // running one at a time from its own point of view, still passes.
+ $second = TestRunner::runTest([MockeryConcurrencyScenarios::class, 'secondMockAcrossSuspend']);
+ Assert::same($second->status, Status::Aborted);
+
+ $first = TestRunner::runTest([MockeryConcurrencyScenarios::class, 'firstMockAcrossSuspend']);
+ Assert::same($first->status, Status::Passed);
+ }
+
/**
* Whether the test's assertion history holds a record with the given success flag — i.e. the
* bridge reported the mock verification (fulfilled or failed) to the Assert plugin.
diff --git a/bridge/mockery/tests/Self/MockeryUnderFibersTest.php b/bridge/mockery/tests/Self/MockeryUnderFibersTest.php
new file mode 100644
index 0000000..b62f8b8
--- /dev/null
+++ b/bridge/mockery/tests/Self/MockeryUnderFibersTest.php
@@ -0,0 +1,48 @@
+expects('count')->once()->andReturn(4);
+
+ Assert::same($mock->count(), 4);
+ }
+
+ #[RunInRevolt]
+ public function mockVerifiesAcrossARevoltAwait(): void
+ {
+ /** @var \Mockery\MockInterface&\Countable $mock */
+ $mock = \Mockery::mock(\Countable::class);
+ $mock->expects('count')->once()->andReturn(7);
+
+ $suspension = EventLoop::getSuspension();
+ EventLoop::delay(0.001, static fn() => $suspension->resume());
+ $suspension->suspend();
+
+ Assert::same($mock->count(), 7);
+ }
+}
diff --git a/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php b/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php
new file mode 100644
index 0000000..273b1f4
--- /dev/null
+++ b/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php
@@ -0,0 +1,44 @@
+expects('count')->once()->andReturn(1);
+
+ \Fiber::suspend();
+
+ $mock->count();
+ }
+
+ public function secondMockAcrossSuspend(): void
+ {
+ /** @var MockInterface&\Countable $mock */
+ $mock = \Mockery::mock(\Countable::class);
+ $mock->expects('count')->once()->andReturn(2);
+
+ \Fiber::suspend();
+
+ $mock->count();
+ }
+}
diff --git a/bridge/revolt/README.md b/bridge/revolt/README.md
new file mode 100644
index 0000000..f133d9b
--- /dev/null
+++ b/bridge/revolt/README.md
@@ -0,0 +1,60 @@
+
+
+
+
+
Revolt event-loop bridge
+
+
+
+[](https://php-testo.github.io)
+[](https://boosty.to/roxblnfk)
+
+
+
+
+
+> [!IMPORTANT]
+> ## 🪞 This is a read-only mirror.
+>
+> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `bridge/revolt/`. This repository is **automatically synchronized** from there on every release.
+>
+> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here.
+
+## About
+
+Runs a test on the process-global [Revolt](https://revolt.run) event loop, so the test body may `await` real async work (timers, streams, `Future::await()`, amphp libraries) and resume without blocking the process.
+
+- `#[RunInRevolt]` — run a test on the Revolt event loop for real async I/O. Applied to a class, it drives every test in the case.
+
+Suspension must go through a Revolt `Suspension` bound to a watcher (I/O, timer) — a bare `\Fiber::suspend()` has no resumer on the loop (that is the plain-fiber `#[RunInFiber]` from `testo/fiber`, a different concern).
+
+### Strategy
+
+`#[RunInRevolt]` takes a `Strategy` that chooses when the loop is entered:
+
+- `Strategy::PerTest` (default) — each test's whole pipeline is placed on the loop individually, one test at a time, each run to completion before the next. No interleaving.
+- `Strategy::PerCase` — launches the whole case's tests **concurrently** on one loop run via `CaseInfo`'s batch runner, interleaving at their await points.
+
+Either way the whole per-test pipeline runs inside the loop fiber. Testo's scoped-state guards (assertion collector, messenger) hold their state per fiber, so concurrent tests stay isolated — each reads its own assertion/messenger state across every switch.
+
+```php
+use Testo\Bridge\Revolt\RunInRevolt;
+use Testo\Bridge\Revolt\Strategy;
+
+#[RunInRevolt(Strategy::PerTest)]
+final class TimersTest { /* ... */ }
+```
+
+## Install
+
+```bash
+composer require --dev testo/bridge-revolt
+```
+
+[](https://packagist.org/packages/testo/bridge-revolt)
+[](https://packagist.org/packages/testo/bridge-revolt)
+[](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
+[](https://packagist.org/packages/testo/bridge-revolt/stats)
diff --git a/bridge/revolt/composer.json b/bridge/revolt/composer.json
new file mode 100644
index 0000000..eee6657
--- /dev/null
+++ b/bridge/revolt/composer.json
@@ -0,0 +1,36 @@
+{
+ "name": "testo/bridge-revolt",
+ "description": "Revolt event-loop bridge for the Testo testing framework: run tests on the real event loop for async I/O.",
+ "license": "BSD-3-Clause",
+ "type": "library",
+ "keywords": [
+ "testo",
+ "revolt",
+ "event-loop",
+ "async",
+ "amphp"
+ ],
+ "authors": [
+ {
+ "name": "Aleksei Gagarin (roxblnfk)",
+ "homepage": "https://github.com/roxblnfk"
+ }
+ ],
+ "require": {
+ "php": ">=8.2",
+ "revolt/event-loop": "^1.0",
+ "testo/testo": "0.10.20 - 1"
+ },
+ "autoload": {
+ "psr-4": {
+ "Testo\\Bridge\\Revolt\\": "src/"
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ }
+}
diff --git a/bridge/revolt/src/Internal/RevoltTestBatchRunner.php b/bridge/revolt/src/Internal/RevoltTestBatchRunner.php
new file mode 100644
index 0000000..8f44770
--- /dev/null
+++ b/bridge/revolt/src/Internal/RevoltTestBatchRunner.php
@@ -0,0 +1,117 @@
+ 0;
+ }
+
+ /**
+ * Runs a single handler as a coroutine on the loop and blocks the current fiber until it returns.
+ *
+ * @param callable(): TestResult $handler
+ */
+ public static function runOnLoop(callable $handler): TestResult
+ {
+ $suspension = EventLoop::getSuspension();
+
+ EventLoop::queue(static function () use ($suspension, $handler): void {
+ ++self::$depth;
+ try {
+ $suspension->resume($handler());
+ } catch (\Throwable $e) {
+ $suspension->throw($e);
+ } finally {
+ --self::$depth;
+ }
+ });
+
+ /** @var TestResult */
+ return $suspension->suspend();
+ }
+
+ /**
+ * Launches every handler as its own coroutine on the loop at once and blocks the current fiber
+ * until all of them finish, preserving input order in the result.
+ *
+ * @param list $handlers
+ * @return list
+ */
+ public function __invoke(array $handlers): array
+ {
+ if ($handlers === []) {
+ return [];
+ }
+
+ $suspension = EventLoop::getSuspension();
+ $results = [];
+ $errors = [];
+ $pending = \count($handlers);
+
+ foreach ($handlers as $i => $handler) {
+ EventLoop::queue(static function () use ($i, $handler, $suspension, &$results, &$errors, &$pending): void {
+ ++self::$depth;
+ try {
+ $results[$i] = $handler();
+ } catch (\Throwable $e) {
+ $errors[$i] = $e;
+ } finally {
+ --self::$depth;
+ --$pending === 0 and $suspension->resume();
+ }
+ });
+ }
+
+ $suspension->suspend();
+
+ // Safety net: the loop must not unwind before every test finished. If a handler ever strands its
+ // loop fiber (a bare suspend with no resumer), this suspension would return with tests missing —
+ // fail loudly rather than silently dropping tests (a green run that ran nothing is the worst
+ // outcome). With the fiber-local guards in place this never fires in practice.
+ $done = \count($results) + \count($errors);
+ $done === \count($handlers) or throw new \RuntimeException(\sprintf(
+ 'Revolt PerCase lost %d of %d test(s): the event loop unwound before they finished.',
+ \count($handlers) - $done,
+ \count($handlers),
+ ));
+
+ $errors === [] or throw \reset($errors);
+
+ \ksort($results);
+
+ return \array_values($results);
+ }
+}
diff --git a/bridge/revolt/src/Internal/RunInRevoltInterceptor.php b/bridge/revolt/src/Internal/RunInRevoltInterceptor.php
new file mode 100644
index 0000000..9f45515
--- /dev/null
+++ b/bridge/revolt/src/Internal/RunInRevoltInterceptor.php
@@ -0,0 +1,72 @@
+options->strategy === Strategy::PerCase
+ ? $next($info->withBatchRunner(new RevoltTestBatchRunner()))
+ : $next($info);
+ }
+
+ #[\Override]
+ public function runTest(TestInfo $info, callable $next): TestResult
+ {
+ // A case-level runner already drove us onto the loop (Strategy::PerCase) — just run the pipeline.
+ if (RevoltTestBatchRunner::active()) {
+ return $next($info);
+ }
+
+ // Strategy::PerTest (or a method-level attribute): put this test's whole pipeline on the loop, so
+ // the fiber-local guards inner to us run in the same fiber as the test body.
+ return RevoltTestBatchRunner::runOnLoop(static fn(): TestResult => $next($info));
+ }
+}
diff --git a/bridge/revolt/src/RunInRevolt.php b/bridge/revolt/src/RunInRevolt.php
new file mode 100644
index 0000000..c17ef64
--- /dev/null
+++ b/bridge/revolt/src/RunInRevolt.php
@@ -0,0 +1,48 @@
+ $suspension->resume('ready'));
+ * Assert::same($suspension->suspend(), 'ready'); // the loop runs while we wait
+ * }
+ * ```
+ *
+ * Unlike the plain-fiber `#[RunInFiber]` (the `testo/fiber` plugin, where Testo's own scheduler drives
+ * the fiber), here the **Revolt loop** owns the fiber, so suspension must go through a Revolt
+ * `Suspension` bound to a watcher (I/O, timer) — a bare `\Fiber::suspend()` has no resumer on the loop.
+ *
+ * The {@see Strategy} chooses when the loop is entered — {@see Strategy::PerTest} (default, one test on
+ * the loop at a time, each run to completion) or {@see Strategy::PerCase} (launches the whole case's tests
+ * concurrently on one loop run, interleaving at their await points). Either way the whole per-test
+ * pipeline runs inside the loop fiber; Testo's scoped-state guards hold their state per fiber (see
+ * {@see \Internal\Fiber\FiberLocal}), so concurrent tests stay isolated.
+ *
+ * @api
+ */
+#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
+#[FallbackInterceptor(RunInRevoltInterceptor::class)]
+final readonly class RunInRevolt implements Interceptable
+{
+ public function __construct(
+ public Strategy $strategy = Strategy::PerTest,
+ ) {}
+}
diff --git a/bridge/revolt/src/Strategy.php b/bridge/revolt/src/Strategy.php
new file mode 100644
index 0000000..4af5e60
--- /dev/null
+++ b/bridge/revolt/src/Strategy.php
@@ -0,0 +1,31 @@
+ $suspension->resume());
+ $suspension->suspend();
+
+ Assert::same(1, 1);
+ }
+
+ public function secondAwaitsATimer(): void
+ {
+ $suspension = EventLoop::getSuspension();
+ EventLoop::delay(0.001, static fn() => $suspension->resume());
+ $suspension->suspend();
+
+ Assert::same(2, 2);
+ }
+}
diff --git a/bridge/revolt/tests/Self/RunInRevoltTest.php b/bridge/revolt/tests/Self/RunInRevoltTest.php
new file mode 100644
index 0000000..a6c3aec
--- /dev/null
+++ b/bridge/revolt/tests/Self/RunInRevoltTest.php
@@ -0,0 +1,36 @@
+ $suspension->resume('resumed'));
+
+ Assert::same($suspension->suspend(), 'resumed');
+ }
+}
diff --git a/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php b/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php
new file mode 100644
index 0000000..33ebb69
--- /dev/null
+++ b/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php
@@ -0,0 +1,67 @@
+runTestCase($this->caseInfo(), $next);
+
+ Assert::notNull($captured?->batchRunner);
+ }
+
+ public function perTestStrategyLeavesTheCaseRunnerUnset(): void
+ {
+ $captured = null;
+ $next = static function (CaseInfo $info) use (&$captured): CaseResult {
+ $captured = $info;
+ return new CaseResult(results: [], status: Status::Passed);
+ };
+
+ (new RunInRevoltInterceptor(new RunInRevolt(Strategy::PerTest)))
+ ->runTestCase($this->caseInfo(), $next);
+
+ Assert::same(null, $captured?->batchRunner);
+ }
+
+ public function defaultStrategyIsPerTest(): void
+ {
+ Assert::same(Strategy::PerTest, (new RunInRevolt())->strategy);
+ }
+
+ private function caseInfo(): CaseInfo
+ {
+ return new CaseInfo(definition: new CaseDefinition(name: 'RevoltCase', type: 'test'));
+ }
+}
diff --git a/bridge/revolt/tests/suites.php b/bridge/revolt/tests/suites.php
new file mode 100644
index 0000000..8829cb6
--- /dev/null
+++ b/bridge/revolt/tests/suites.php
@@ -0,0 +1,24 @@
+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 +117,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 a752e40..35d4c4a 100644
--- a/core/Application/Internal/MessengerHub.php
+++ b/core/Application/Internal/MessengerHub.php
@@ -5,10 +5,11 @@
namespace Testo\Application\Internal;
use Psr\EventDispatcher\EventDispatcherInterface;
+use Internal\Fiber\FiberLocal;
use Testo\Application\Internal\Messenger\State;
-use Testo\Application\Internal\Messenger\MutableContainer;
use Testo\Common\Messenger;
use Testo\Common\Messenger\Channel;
+use Testo\Core\Context\TestIdentity;
use Testo\Core\Log\Level;
use Testo\Core\Log\Message;
use Testo\Core\Log\MessageLog;
@@ -20,16 +21,21 @@
* the state/scope model of {@see \Internal\Container\ObjectContainer}: each scope owns an
* isolated message buffer, while the {@see MessageReceived} event stream stays global.
*
+ * The active {@see State} is kept in a {@see FiberLocal} so a scope entered inside a test fiber stays
+ * isolated to that fiber: while several tests interleave on one event loop, each reads its own buffer
+ * across every switch, with no swapping at the suspension boundary.
+ *
* @internal
*/
final readonly class MessengerHub implements Messenger
{
- private MutableContainer $state;
+ /** @var FiberLocal */
+ private FiberLocal $current;
public function __construct(
private EventDispatcherInterface $eventDispatcher,
) {
- $this->state = new MutableContainer(new State($this->eventDispatcher));
+ $this->current = new FiberLocal(new State($this->eventDispatcher));
}
#[\Override]
@@ -37,8 +43,7 @@ public function log(string $channel, string $content, Level $level = Level::Info
{
// The active state records the message and announces it (or holds the event, in a holdEvents
// fork, until commit); it also guards against avalanche recursion during dispatch.
- /** @psalm-suppress ArgumentTypeCoercion */
- $this->state->state->record(new Message(\microtime(true), $channel, $level, $content, $context));
+ $this->current->get()->record(new Message(\microtime(true), $channel, $level, $content, $context));
}
#[\Override]
@@ -48,81 +53,27 @@ 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);
- try {
- $this->state->state = $new;
- if (\Fiber::getCurrent() === null) {
- return $scope($this);
- }
-
- // Wrap scope into a fiber so the parent state is restored across suspensions.
- $self = $this;
- $fiber = new \Fiber(static fn() => $scope($self));
- $value = $fiber->start();
- while (!$fiber->isTerminated()) {
- $this->state->state = $old;
- try {
- $resume = \Fiber::suspend($value);
- } catch (\Throwable $e) {
- $this->state->state = $new;
- $value = $fiber->throw($e);
- continue;
- }
-
- $this->state->state = $new;
- $value = $fiber->resume($resume);
- }
-
- return $fiber->getReturn();
- } finally {
- $this->state->state = $old;
- $new->destroy();
- }
+ // 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.
+ $new = new State($this->eventDispatcher, identity: $identity);
+ return $this->current->scope($new, fn(): mixed => $scope($this), $new->destroy(...));
}
#[\Override]
public function fork(\Closure $fork, bool $holdEvents = false): mixed
{
- $old = $this->state->state;
- $new = $old->fork($holdEvents);
- try {
- $this->state->state = $new;
- if (\Fiber::getCurrent() === null) {
- return $fork($new->commit(...));
- }
-
- // Wrap the fork into a fiber so the parent state is restored across suspensions.
- $fiber = new \Fiber(static fn() => $fork($new->commit(...)));
- $value = $fiber->start();
- while (!$fiber->isTerminated()) {
- $this->state->state = $old;
- try {
- $resume = \Fiber::suspend($value);
- } catch (\Throwable $e) {
- $this->state->state = $new;
- $value = $fiber->throw($e);
- continue;
- }
-
- $this->state->state = $new;
- $value = $fiber->resume($resume);
- }
-
- return $fiber->getReturn();
- } finally {
- // Restore the parent, but do NOT destroy the fork: the `$commit` callable may be invoked
- // after this returns (the caller decides to keep/drop the branch only once it sees the
- // result). commit() clears the fork's own buffer; an abandoned fork is freed by GC.
- $this->state->state = $old;
- }
+ // A fork is a child branch on top of the active state. No destroy: the `$commit` callable may be
+ // invoked after this returns (the caller keeps/drops the branch once it sees the result); commit()
+ // clears the fork's own buffer, and an abandoned fork is freed by GC.
+ $new = $this->current->get()->fork($holdEvents);
+ return $this->current->scope($new, static fn(): mixed => $fork($new->commit(...)));
}
#[\Override]
public function getMessages(): MessageLog
{
- return new MessageLog($this->state->state->getMessages());
+ return new MessageLog($this->current->get()->getMessages());
}
}
diff --git a/core/Common/Messenger.php b/core/Common/Messenger.php
index 58cd240..3ae90bd 100644
--- a/core/Common/Messenger.php
+++ b/core/Common/Messenger.php
@@ -4,6 +4,7 @@
namespace Testo\Common;
+use Testo\Core\Context\TestIdentity;
use Testo\Core\Log\Level;
use Testo\Core\Log\Message;
use Testo\Core\Log\MessageLog;
@@ -80,11 +81,16 @@ 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.
*
+ * When an `$identity` is given, every {@see MessageReceived} dispatched from within the scope is
+ * stamped with it, so a consumer can attribute the message to that test even while other tests
+ * interleave their output on the same stream.
+ *
* @template T
* @param \Closure(self): T $scope
+ * @param TestIdentity|null $identity Test this scope's messages belong to, if any.
* @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/TestIdentity.php b/core/Core/Context/TestIdentity.php
new file mode 100644
index 0000000..4d9be9a
--- /dev/null
+++ b/core/Core/Context/TestIdentity.php
@@ -0,0 +1,31 @@
+ $arguments Arguments to pass to the test method.
* @param array $attributes
+ * @param TestIdentity|null $identity Identity to carry; a fresh random one is minted when omitted.
*/
public function __construct(
public string $name,
@@ -27,7 +33,10 @@ public function __construct(
public TestDefinition $testDefinition,
public array $arguments = [],
public array $attributes = [],
- ) {}
+ ?TestIdentity $identity = null,
+ ) {
+ $this->identity = $identity ?? TestIdentity::generate();
+ }
public function with(
?array $arguments = null,
@@ -38,6 +47,7 @@ public function with(
testDefinition: $this->testDefinition,
arguments: $arguments ?? $this->arguments,
attributes: $this->attributes,
+ identity: $this->identity,
);
}
}
diff --git a/core/Event/Message/MessageReceived.php b/core/Event/Message/MessageReceived.php
index 1d885b4..1e21cd6 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\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/Teamcity/Teamcity/Formatter.php b/core/Output/Teamcity/Teamcity/Formatter.php
index b9af582..379c1f4 100644
--- a/core/Output/Teamcity/Teamcity/Formatter.php
+++ b/core/Output/Teamcity/Teamcity/Formatter.php
@@ -27,9 +27,11 @@ private function __construct() {}
* @param \ReflectionClass