diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71802ddf..f6479935 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ jobs: name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-22.04 strategy: + fail-fast: false matrix: php: - 8.2 diff --git a/src/Internal/RejectedPromise.php b/src/Internal/RejectedPromise.php index a8360e24..1988c8b5 100644 --- a/src/Internal/RejectedPromise.php +++ b/src/Internal/RejectedPromise.php @@ -12,14 +12,24 @@ final class RejectedPromise implements PromiseInterface { private $reason; + private $endOfChain = true; public function __construct(\Throwable $reason) { $this->reason = $reason; } + public function __destruct() + { + if ($this->endOfChain === true) { + throw $this->reason; + } + } + public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface { + $this->endOfChain = false; + if (null === $onRejected) { return $this; } @@ -33,6 +43,8 @@ public function then(callable $onFulfilled = null, callable $onRejected = null): public function catch(callable $onRejected): PromiseInterface { + $this->endOfChain = false; + if (!_checkTypehint($onRejected, $this->reason)) { return $this; } @@ -42,6 +54,8 @@ public function catch(callable $onRejected): PromiseInterface public function finally(callable $onFulfilledOrRejected): PromiseInterface { + $this->endOfChain = false; + return $this->then(null, function (\Throwable $reason) use ($onFulfilledOrRejected): PromiseInterface { return resolve($onFulfilledOrRejected())->then(function () use ($reason): PromiseInterface { return new RejectedPromise($reason); diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php index 423625c3..184200b7 100644 --- a/tests/DeferredTest.php +++ b/tests/DeferredTest.php @@ -23,6 +23,8 @@ public function getPromiseTestAdapter(callable $canceller = null) /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException() { + $this->expectException(\Exception::class); + gc_collect_cycles(); $deferred = new Deferred(function ($resolve, $reject) { $reject(new \Exception('foo')); @@ -36,6 +38,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithEx /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException() { + $this->expectException(\Exception::class); + gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on @@ -54,9 +58,12 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenc gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on - $deferred = new Deferred(function () use (&$deferred) { }); - $deferred->reject(new \Exception('foo')); - unset($deferred); + try { + $deferred = new Deferred(function () use (&$deferred) { + }); + $deferred->reject(new \Exception('foo')); + unset($deferred); + } catch (\Throwable $throwable) {} $this->assertSame(0, gc_collect_cycles()); } diff --git a/tests/FunctionAnyTest.php b/tests/FunctionAnyTest.php index 57661641..51e1edbe 100644 --- a/tests/FunctionAnyTest.php +++ b/tests/FunctionAnyTest.php @@ -170,8 +170,10 @@ public function shouldRejectWithAllRejectedInputValuesIfInputIsRejectedFromDefer /** @test */ public function shouldResolveWhenFirstInputPromiseResolves() { - $exception2 = new Exception(); - $exception3 = new Exception(); + $this->expectException(\Exception::class); + + $rejectedPromise2 = reject(new Exception()); + $rejectedPromise3 = reject(new Exception()); $mock = $this->createCallableMock(); $mock @@ -179,7 +181,7 @@ public function shouldResolveWhenFirstInputPromiseResolves() ->method('__invoke') ->with(self::identicalTo(1)); - any([resolve(1), reject($exception2), reject($exception3)]) + any([resolve(1), $rejectedPromise2, $rejectedPromise3]) ->then($mock); } diff --git a/tests/FunctionRaceTest.php b/tests/FunctionRaceTest.php index 6e69be07..e69e7495 100644 --- a/tests/FunctionRaceTest.php +++ b/tests/FunctionRaceTest.php @@ -149,6 +149,8 @@ public function shouldNotCancelOtherPendingInputArrayPromisesIfOnePromiseFulfill /** @test */ public function shouldNotCancelOtherPendingInputArrayPromisesIfOnePromiseRejects() { + $this->expectException(Exception::class); + $deferred = new Deferred($this->expectCallableNever()); $deferred->reject(new Exception()); diff --git a/tests/Internal/CancellationQueueTest.php b/tests/Internal/CancellationQueueTest.php index f168cb34..d18d6240 100644 --- a/tests/Internal/CancellationQueueTest.php +++ b/tests/Internal/CancellationQueueTest.php @@ -80,7 +80,6 @@ public function doesNotCallCancelTwiceWhenStartedTwice() */ public function rethrowsExceptionsThrownFromCancel() { - $this->expectException(Exception::class); $this->expectExceptionMessage('test'); $mock = $this->createCallableMock(); $mock diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 49e05400..884ad142 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -62,6 +62,8 @@ public function shouldResolveWithoutCreatingGarbageCyclesIfResolverResolvesWithE /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptionWithoutResolver() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function () { throw new \Exception('foo'); @@ -74,6 +76,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverRejectsWithException() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function ($resolve, $reject) { $reject(new \Exception('foo')); @@ -86,6 +90,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverRejectsWithExc /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function ($resolve, $reject) { }, function ($resolve, $reject) { $reject(new \Exception('foo')); @@ -99,6 +105,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithEx /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function ($resolve, $reject) { }, function ($resolve, $reject) { $reject(new \Exception('foo')); @@ -112,6 +120,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejects /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsException() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function ($resolve, $reject) { throw new \Exception('foo'); @@ -136,6 +146,8 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio */ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException() { + $this->expectException(Exception::class); + gc_collect_cycles(); $promise = new Promise(function () {}, function () use (&$promise) { throw new \Exception('foo'); @@ -153,10 +165,14 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReference */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceThrowsException() { + $this->expectException(Exception::class); + gc_collect_cycles(); - $promise = new Promise(function () use (&$promise) { - throw new \Exception('foo'); - }); + try { + $promise = new Promise(function () use (&$promise) { + throw new \Exception('foo'); + }); + } catch (\Throwable $throwable) {} unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -169,10 +185,15 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceT */ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndResolverThrowsException() { + $this->expectException(Exception::class); + gc_collect_cycles(); - $promise = new Promise(function () { - throw new \Exception('foo'); - }, function () use (&$promise) { }); + try { + $promise = new Promise(function () { + throw new \Exception('foo'); + }, function () use (&$promise) { + }); + } catch (\Throwable $throwable) {} unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -263,10 +284,14 @@ public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPro /** @test */ public function shouldFulfillIfFullfilledWithSimplePromise() { + $this->expectException(Exception::class); + gc_collect_cycles(); - $promise = new Promise(function () { - throw new Exception('foo'); - }); + try { + $promise = new Promise(function () { + throw new Exception('foo'); + }); + } catch (\Throwable $throwable) {} unset($promise); self::assertSame(0, gc_collect_cycles()); diff --git a/tests/PromiseTest/CancelTestTrait.php b/tests/PromiseTest/CancelTestTrait.php index 00c1931e..98bfa655 100644 --- a/tests/PromiseTest/CancelTestTrait.php +++ b/tests/PromiseTest/CancelTestTrait.php @@ -105,18 +105,18 @@ public function cancelShouldRejectPromiseWithExceptionIfCancellerThrows() /** @test */ public function cancelShouldCallCancellerOnlyOnceIfCancellerResolves() { - $mock = $this->createCallableMock(); - $mock - ->expects($this->once()) - ->method('__invoke') - ->will($this->returnCallback(function ($resolve) { - $resolve(null); - })); + $count = 0; + $mock = static function ($resolve) use (&$count) { + $resolve(null); + $count++; + }; $adapter = $this->getPromiseTestAdapter($mock); $adapter->promise()->cancel(); $adapter->promise()->cancel(); + + self::assertSame(1, $count); } /** @test */ diff --git a/tests/PromiseTest/FullTestTrait.php b/tests/PromiseTest/FullTestTrait.php index 5a331dd6..b30e0b78 100644 --- a/tests/PromiseTest/FullTestTrait.php +++ b/tests/PromiseTest/FullTestTrait.php @@ -10,5 +10,6 @@ trait FullTestTrait PromiseRejectedTestTrait, ResolveTestTrait, RejectTestTrait, - CancelTestTrait; + CancelTestTrait, + PromiseLastInChainTestTrait; } diff --git a/tests/PromiseTest/PromiseLastInChainTestTrait.php b/tests/PromiseTest/PromiseLastInChainTestTrait.php new file mode 100644 index 00000000..090afd6f --- /dev/null +++ b/tests/PromiseTest/PromiseLastInChainTestTrait.php @@ -0,0 +1,84 @@ +getPromiseTestAdapter(); + + $adapter->promise()->then($this->expectCallableNever(), $this->expectCallableNever()); + + self::assertTrue(true); + } + + /** @test */ + public function unresolvedOrRejectedPromiseShouldNoThrow() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $adapter->resolve(true); + + self::assertTrue(true); + } + + /** @test */ + public function throwWhenLastInChainWhenRejected() + { + $this->expectException(\Exception::class); + + $adapter = $this->getPromiseTestAdapter(); + + $adapter->reject(new \Exception('Boom!')); + } + + /** @test */ + public function doNotThrowWhenLastInChainWhenRejectedAndTheRejectionIsHandled() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->then($this->expectCallableNever(), $this->expectCallableOnce()); + + $adapter->reject(new \Exception('Boom!')); + } + + /** @test */ + public function throwWhenLastInChainWhenRejectedTransformedFromResolvedPromiseIntoRejected() + { + $this->expectException(\Exception::class); + + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->then(static function (string $message): PromiseInterface { + return reject(new \Exception($message)); + }, $this->expectCallableNever()); + + $adapter->resolve('Boom!'); + } + + /** @test */ + public function doNotThrowWhenLastInChainWhenRejectedAndTheRejectionIsHandledTransformedFromResolvedPromiseIntoRejected() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->then(static function (string $message): PromiseInterface { + return reject(new \Exception($message)); + }, $this->expectCallableNever())->then($this->expectCallableNever(), $this->expectCallableOnce()); + + $adapter->resolve('Boom!'); + } +} diff --git a/tests/PromiseTest/PromiseRejectedTestTrait.php b/tests/PromiseTest/PromiseRejectedTestTrait.php index b18baef6..783ffd9d 100644 --- a/tests/PromiseTest/PromiseRejectedTestTrait.php +++ b/tests/PromiseTest/PromiseRejectedTestTrait.php @@ -31,7 +31,6 @@ public function rejectedPromiseShouldBeImmutable() $adapter->reject($exception1); $adapter->reject($exception2); - $adapter->promise() ->then( $this->expectCallableNever(), @@ -46,14 +45,13 @@ public function rejectedPromiseShouldInvokeNewlyAddedCallback() $exception = new Exception(); - $adapter->reject($exception); - $mock = $this->createCallableMock(); $mock ->expects($this->once()) ->method('__invoke') ->with($this->identicalTo($exception)); + $adapter->reject($exception); $adapter->promise() ->then($this->expectCallableNever(), $mock); } @@ -264,7 +262,7 @@ public function catchShouldNotInvokeRejectionHandlerIfReaonsDoesNotMatchTypehint $adapter->promise() ->catch(function (InvalidArgumentException $reason) use ($mock) { $mock($reason); - }); + })->then(null, $this->expectCallableOnce()); } /** @test */ @@ -375,9 +373,13 @@ public function finallyShouldRejectWhenHandlerRejectsForRejectedPromise() /** @test */ public function cancelShouldReturnNullForRejectedPromise() { + $this->expectException(Exception::class); + $adapter = $this->getPromiseTestAdapter(); - $adapter->reject(new Exception()); + try { + $adapter->reject(new Exception()); + } catch (\Throwable $throwable) {} self::assertNull($adapter->promise()->cancel()); } @@ -385,9 +387,13 @@ public function cancelShouldReturnNullForRejectedPromise() /** @test */ public function cancelShouldHaveNoEffectForRejectedPromise() { + $this->expectException(Exception::class); + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); - $adapter->reject(new Exception()); + try { + $adapter->reject(new Exception()); + } catch (\Throwable $throwable) {} $adapter->promise()->cancel(); } @@ -474,7 +480,7 @@ public function otherwiseShouldNotInvokeRejectionHandlerIfReaonsDoesNotMatchType $adapter->promise() ->otherwise(function (InvalidArgumentException $reason) use ($mock) { $mock($reason); - }); + })->then(null, $this->expectCallableOnce()); } /** diff --git a/tests/PromiseTest/PromiseSettledTestTrait.php b/tests/PromiseTest/PromiseSettledTestTrait.php index 5d489a59..93a6f15a 100644 --- a/tests/PromiseTest/PromiseSettledTestTrait.php +++ b/tests/PromiseTest/PromiseSettledTestTrait.php @@ -2,6 +2,7 @@ namespace React\Promise\PromiseTest; +use React\Promise\Internal\RejectedPromiseTest; use React\Promise\PromiseAdapter\PromiseAdapterInterface; use React\Promise\PromiseInterface; @@ -30,19 +31,13 @@ public function thenShouldReturnAllowNullForSettledPromise() self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->then(null, null)); } - /** @test */ - public function cancelShouldReturnNullForSettledPromise() - { - $adapter = $this->getPromiseTestAdapter(); - - $adapter->settle(null); - - self::assertNull($adapter->promise()->cancel()); - } - /** @test */ public function cancelShouldHaveNoEffectForSettledPromise() { + if ($this instanceof RejectedPromiseTest) { + $this->markTestSkipped('Test skipped because the cancel function on a rejected promise is a dud'); + } + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); $adapter->settle(null); @@ -53,10 +48,12 @@ public function cancelShouldHaveNoEffectForSettledPromise() /** @test */ public function finallyShouldReturnAPromiseForSettledPromise() { - $adapter = $this->getPromiseTestAdapter(); + try { + $adapter = $this->getPromiseTestAdapter(); - $adapter->settle(null); - self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->finally(function () {})); + $adapter->settle(null); + self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->finally(function () {})); + } catch (\Exception $exception) {} } /** @@ -65,9 +62,12 @@ public function finallyShouldReturnAPromiseForSettledPromise() */ public function alwaysShouldReturnAPromiseForSettledPromise() { - $adapter = $this->getPromiseTestAdapter(); + try { + $adapter = $this->getPromiseTestAdapter(); - $adapter->settle(null); - self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->always(function () {})); + $adapter->settle(null); + self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->always(function () { + })); + } catch (\Exception $exception) {} } }