Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5b1c30b
feat(bridge-revolt): new testo/bridge-revolt — #[RunInRevolt] async t…
roxblnfk Jul 21, 2026
7092a90
feat(bridge-revolt): PerTest / PerCase loop strategies via CaseInfo b…
roxblnfk Jul 23, 2026
f719a1c
chore(release-please): start `testo/bridge-revolt` from v0.1.0
roxblnfk Jul 24, 2026
dbcc19a
feat(bridge-revolt): make PerCase actually run tests concurrently
roxblnfk Jul 24, 2026
1b30e9b
feat(core): add FiberLocal WeakMap primitive for fiber-scoped state
roxblnfk Jul 24, 2026
a4f1206
refactor(messenger): back MessengerHub scope with FiberLocal
roxblnfk Jul 24, 2026
5b62039
refactor(assert): back the assertion collector with FiberLocal
roxblnfk Jul 24, 2026
978e8c3
feat(bridge-revolt): run the whole pipeline on the loop, fix PerCase
roxblnfk Jul 24, 2026
7d6509f
fix(fiber): run the whole pipeline inside the method-level fiber
roxblnfk Jul 24, 2026
85f2232
refactor(fiber): extract FiberLocal into the internal/fiber package
roxblnfk Jul 24, 2026
7ed7e6b
refactor(container): back ObjectContainer scope with FiberLocal
roxblnfk Jul 24, 2026
cb29898
test(container): broaden ObjectContainer coverage
roxblnfk Jul 24, 2026
6ef552b
feat(bridge-mockery): serialize Mockery instead of the fiber trampoline
roxblnfk Jul 24, 2026
7cc0055
test(guards): cover Assert, Messenger and Container under concurrent …
roxblnfk Jul 24, 2026
1f07d00
test(sandbox): add an async fiber-sleep sandbox that finishes staggered
roxblnfk Jul 24, 2026
afbe927
feat(core): give each test an identity and stamp it on MessageReceived
roxblnfk Jul 24, 2026
72cd92d
fix(teamcity): flowId per test so concurrent output stays attributable
roxblnfk Jul 24, 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
6 changes: 6 additions & 0 deletions .github/.release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]*'
Expand Down
62 changes: 22 additions & 40 deletions bridge/mockery/src/Internal/MockeryInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,22 +23,41 @@
*
* 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
*/
#[InterceptorOptions(
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 {
Expand Down Expand Up @@ -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;
}
}
29 changes: 29 additions & 0 deletions bridge/mockery/src/MockeryConcurrencyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Mockery;

/**
* Thrown when a Mockery-guarded test starts while another one is still in flight — i.e. under
* interleaving execution, where sibling tests would clobber each other's mocks.
*
* Mockery's mock container is process-global static state and cannot be isolated per fiber, so the
* bridge serializes Mockery tests. Run them one at a time — {@see \Testo\Fiber\Schedule::Solo} /
* {@see \Testo\Bridge\Revolt\Strategy::PerTest} (or without fibers at all) — rather than interleaved
* ({@see \Testo\Fiber\Schedule::RoundRobin}/`Random`, {@see \Testo\Bridge\Revolt\Strategy::PerCase}).
*
* @api
*/
final class MockeryConcurrencyException extends \RuntimeException
{
public function __construct()
{
parent::__construct(
'Mockery cannot run under interleaving test execution: its mock container is process-global '
. 'and cannot be isolated per fiber, so concurrently running tests would clobber each other\'s '
. 'mocks. Run Mockery tests one at a time (Schedule::Solo / Strategy::PerTest, or no fibers), '
. 'not interleaved (Schedule::RoundRobin/Random, Strategy::PerCase).',
);
}
}
14 changes: 14 additions & 0 deletions bridge/mockery/tests/Feature/MockeryStatusTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Testo\Test;
use Testo\Testing\Attribute\TestingSuite;
use Testo\Testing\Helper\TestRunner;
use Tests\Bridge\Mockery\Stub\MockeryConcurrencyScenarios;
use Tests\Bridge\Mockery\Stub\MockeryResetScenarios;
use Tests\Bridge\Mockery\Stub\MockeryScenarios;

Expand Down Expand Up @@ -71,6 +72,19 @@ public function stateIsResetAfterAFailingTest(): void
Assert::same($next->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.
Expand Down
48 changes: 48 additions & 0 deletions bridge/mockery/tests/Self/MockeryUnderFibersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Mockery\Self;

use Revolt\EventLoop;
use Testo\Assert;
use Testo\Bridge\Mockery\Internal\MockeryInterceptor;
use Testo\Bridge\Mockery\MockeryPlugin;
use Testo\Bridge\Revolt\RunInRevolt;
use Testo\Codecov\Covers;
use Testo\Fiber\RunInFiber;
use Testo\Test;

/**
* Mockery under one-at-a-time fiber execution — the supported case after the trampoline was dropped.
* A mock set up inside a scheduler fiber (Solo) and one carried across a real Revolt await both verify
* normally: with no sibling test running, the process-global container stays this test's throughout.
*/
#[Test]
#[Covers(MockeryPlugin::class, MockeryInterceptor::class)]
final class MockeryUnderFibersTest
{
#[RunInFiber]
public function mockVerifiesInsideASoloFiber(): void
{
/** @var \Mockery\MockInterface&\Countable $mock */
$mock = \Mockery::mock(\Countable::class);
$mock->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);
}
}
44 changes: 44 additions & 0 deletions bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Mockery\Stub;

use Mockery\MockInterface;
use Testo\Fiber\RunInFiber;
use Testo\Fiber\Schedule;
use Testo\Test;

/**
* Two Mockery tests forced to **interleave** on Testo's fiber scheduler ({@see Schedule::RoundRobin}):
* each sets up a mock, yields at `\Fiber::suspend()`, then uses it. Because Mockery's container is
* process-global, the second test to start (while the first is parked mid-mock) must be rejected with a
* {@see \Testo\Bridge\Mockery\MockeryConcurrencyException}. Driven through {@see
* \Testo\Testing\Helper\TestRunner} by the Feature suite.
*/
#[Test]
#[RunInFiber(Schedule::RoundRobin)]
final class MockeryConcurrencyScenarios
{
public function firstMockAcrossSuspend(): void
{
/** @var MockInterface&\Countable $mock */
$mock = \Mockery::mock(\Countable::class);
$mock->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();
}
}
60 changes: 60 additions & 0 deletions bridge/revolt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<p align="center">
<a href="https://github.com/php-testo/testo"><img alt="TESTO"
src="https://github.com/php-testo/.github/blob/1.x/resources/logo-full.svg?raw=true"
style="width: 2in; display: block"
/></a>
</p>

<p align="center">Revolt event-loop bridge</p>

<div align="center">

[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io)
[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk)

</div>

<br />

> [!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
```

[![PHP](https://img.shields.io/packagist/php-v/testo/bridge-revolt.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/bridge-revolt)
[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/bridge-revolt.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/bridge-revolt)
[![License](https://img.shields.io/packagist/l/testo/bridge-revolt.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
[![Total Downloads](https://img.shields.io/packagist/dt/testo/bridge-revolt.svg?style=flat-square)](https://packagist.org/packages/testo/bridge-revolt/stats)
36 changes: 36 additions & 0 deletions bridge/revolt/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading
Loading