diff --git a/composer.json b/composer.json index 68f68b6f..a41ca632 100644 --- a/composer.json +++ b/composer.json @@ -29,18 +29,18 @@ "grpc/grpc": "^1.42", "nesbot/carbon": "^2.66", "psr/log": "^2.0 || ^3.0", - "react/promise": "^2.9", "ramsey/uuid": "^4.7", + "react/promise": "2.9 || ^3.0", "roadrunner-php/roadrunner-api-dto": "^1.3", + "roadrunner-php/version-checker": "^1.0", "spiral/attributes": "^2.8 || ^3.0", + "spiral/roadrunner": "^v2023.2", "spiral/roadrunner-cli": "^2.5", "spiral/roadrunner-kv": "^4.0", "spiral/roadrunner-worker": "^3.0", "symfony/filesystem": "^4.4.20 || ^5.0 || ^6.0", "symfony/http-client": "^4.4.27 || ^5.0 || ^6.0", - "symfony/process": "^5.4 || ^6.0", - "roadrunner-php/version-checker": "^1.0", - "spiral/roadrunner": "^v2023.2" + "symfony/process": "^5.4 || ^6.0" }, "autoload": { "psr-4": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 71bb774a..97ad17a0 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -783,6 +783,15 @@ getID()]]> + + + wrapContext($onFulfilledOrRejected)]]> + wrapContext($onFulfilledOrRejected)]]> + + + $value + + totalMilliseconds]]> @@ -939,27 +948,18 @@ - - Scope - parent::__construct($services, $ctx) - parent::start($handler, $values) - - - createScope - getContext - makeCurrent - onClose - parent::__construct($services, $ctx) - parent::start($handler, $values) - promise - startSignal - + + class Process extends Scope implements ProcessInterface + $result $result + + ProcessInterface + Process @@ -973,69 +973,10 @@ context]]> - - services, $this->context)]]> - - - attach - call - callSignalHandler - createScope - createScope - defer - defer - handleError - handleError - makeCurrent - makeCurrent - makeCurrent - makeCurrent - makeCurrent - makeCurrent - makeCurrent - makeCurrent - services, $this->context)]]> - next - next - next - next - next - nextPromise - nextPromise - nextPromise - nextPromise - onClose - onException - onException - onException - onException - onException - onResult - start - - - detached]]> - layer]]> - cancelID]]> - cancelID]]> - cancelID]]> - cancelID]]> - cancelled]]> - context]]> - coroutine]]> - coroutine]]> - coroutine]]> - deferred]]> - exception]]> - onCancel]]> - onCancel]]> - onCancel]]> - onCancel]]> - onClose]]> - result]]> - scopeContext]]> - services]]> - + + $onFulfilled + $onRejected + context]]> @@ -1067,6 +1008,9 @@ resolveConditions resolveConditions + + $e + @@ -1082,14 +1026,6 @@ - - getLayer - getLayer - isCancelled - onAwait - startScope - startScope - $onRequest $parent @@ -1153,6 +1089,12 @@ $promiseOrValue $promiseOrValue + + $reasons + + + $reduce($c, $value, $i++, $total) + diff --git a/src/Internal/Promise/CancellationQueue.php b/src/Internal/Promise/CancellationQueue.php new file mode 100644 index 00000000..f4b5478f --- /dev/null +++ b/src/Internal/Promise/CancellationQueue.php @@ -0,0 +1,63 @@ +started) { + return; + } + + $this->started = true; + $this->drain(); + } + + public function enqueue(mixed $cancellable): void + { + if (!\is_object($cancellable) + || !\method_exists($cancellable, 'then') + || !\method_exists($cancellable, 'cancel') + ) { + return; + } + + $length = \array_push($this->queue, $cancellable); + + if ($this->started && 1 === $length) { + $this->drain(); + } + } + + private function drain(): void + { + for ($i = \key($this->queue); isset($this->queue[$i]); $i++) { + $cancellable = $this->queue[$i]; + + $exception = null; + + try { + $cancellable->cancel(); + } catch (\Throwable $exception) { + } + + unset($this->queue[$i]); + + if ($exception) { + throw $exception; + } + } + + $this->queue = []; + } +} diff --git a/src/Internal/Promise/Reasons.php b/src/Internal/Promise/Reasons.php new file mode 100644 index 00000000..91ed47da --- /dev/null +++ b/src/Internal/Promise/Reasons.php @@ -0,0 +1,89 @@ + + * @implements ArrayAccess + */ +final class Reasons extends RuntimeException implements Iterator, ArrayAccess, Countable +{ + /** + * @param array $collection + */ + public function __construct( + public array $collection, + ) { + parent::__construct(); + } + + public function current(): mixed + { + return \current($this->collection); + } + + public function next(): void + { + \next($this->collection); + } + + public function key(): string|int|null + { + return \key($this->collection); + } + + public function valid(): bool + { + return null !== \key($this->collection); + } + + public function rewind(): void + { + \reset($this->collection); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->collection[$offset]); + } + + /** + * @param TKey $offset + * @return TValue + */ + public function offsetGet(mixed $offset): Traversable + { + return $this->collection[$offset]; + } + + /** + * @param TKey $offset + * @param TValue $value + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->collection[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->collection[$offset]); + } + + public function count(): int + { + return \count($this->collection); + } +} diff --git a/src/Internal/Transport/CompletableResult.php b/src/Internal/Transport/CompletableResult.php index e077e181..6b2b5dc9 100644 --- a/src/Internal/Transport/CompletableResult.php +++ b/src/Internal/Transport/CompletableResult.php @@ -11,13 +11,16 @@ namespace Temporal\Internal\Transport; -use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use Temporal\Worker\LoopInterface; use Temporal\Workflow; use Temporal\Workflow\WorkflowContextInterface; +/** + * @template T of mixed + * @implements CompletableResultInterface + */ class CompletableResult implements CompletableResultInterface { /** @@ -73,7 +76,6 @@ public function __construct( $this->deferred = new Deferred(); $this->layer = $layer; - /** @var CancellablePromiseInterface $current */ $this->promise = $promise->then( \Closure::fromCallable([$this, 'onFulfilled']), \Closure::fromCallable([$this, 'onRejected']), @@ -97,35 +99,31 @@ public function getValue() } /** - * {@inheritDoc} + * @param (callable(mixed): mixed)|null $onFulfilled + * @param (callable(\Throwable): mixed)|null $onRejected + * @param callable|null $onProgress + * + * @return PromiseInterface */ public function then( - callable $onFulfilled = null, - callable $onRejected = null, - callable $onProgress = null + ?callable $onFulfilled = null, + ?callable $onRejected = null, + ?callable $onProgress = null, ): PromiseInterface { - Workflow::setCurrentContext($this->context); - - /** @var CancellablePromiseInterface $promise */ - $promise = $this->promise() - ->then($onFulfilled, $onRejected, $onProgress); - - return $promise; + return $this->promise() + ->then($this->wrapContext($onFulfilled), $this->wrapContext($onRejected)); //return new Future($promise, $this->worker); } /** - * @return PromiseInterface + * @return PromiseInterface */ public function promise(): PromiseInterface { return $this->deferred->promise(); } - /** - * @param mixed $result - */ - private function onFulfilled($result): void + private function onFulfilled(mixed $result): void { $this->resolved = true; $this->value = $result; @@ -154,4 +152,50 @@ function () use ($e): void { } ); } + + public function catch(callable $onRejected): PromiseInterface + { + return $this->promise() + ->catch($this->wrapContext($onRejected)); + } + + public function finally(callable $onFulfilledOrRejected): PromiseInterface + { + return $this->promise() + ->finally($this->wrapContext($onFulfilledOrRejected)); + } + + public function cancel(): void + { + Workflow::setCurrentContext($this->context); + $this->promise()->cancel(); + } + + public function otherwise(callable $onRejected): PromiseInterface + { + return $this->catch($this->wrapContext($onRejected)); + } + + public function always(callable $onFulfilledOrRejected): PromiseInterface + { + return $this->finally($this->wrapContext($onFulfilledOrRejected)); + } + + /** + * @template TParam of mixed + * @template TReturn of mixed + * @param (callable(TParam): TReturn)|null $callback + * @return ($callback is null ? null : (callable(TParam): TReturn)) + */ + private function wrapContext(?callable $callback): ?callable + { + if ($callback === null) { + return null; + } + + return function (mixed $value = null) use ($callback): mixed { + Workflow::setCurrentContext($this->context); + return $callback($value); + }; + } } diff --git a/src/Internal/Transport/CompletableResultInterface.php b/src/Internal/Transport/CompletableResultInterface.php index 921696fc..f4ceff28 100644 --- a/src/Internal/Transport/CompletableResultInterface.php +++ b/src/Internal/Transport/CompletableResultInterface.php @@ -12,9 +12,12 @@ namespace Temporal\Internal\Transport; use React\Promise\PromiseInterface; -use React\Promise\PromisorInterface; -interface CompletableResultInterface extends PromisorInterface, PromiseInterface +/** + * @template T + * @extends PromiseInterface + */ +interface CompletableResultInterface extends PromiseInterface { /** * @return bool diff --git a/src/Internal/Workflow/Process/Process.php b/src/Internal/Workflow/Process/Process.php index cbec76b9..8e5459a0 100644 --- a/src/Internal/Workflow/Process/Process.php +++ b/src/Internal/Workflow/Process/Process.php @@ -18,8 +18,12 @@ use Temporal\Internal\ServiceContainer; use Temporal\Internal\Workflow\WorkflowContext; use Temporal\Worker\LoopInterface; +use Temporal\Workflow\CancellationScopeInterface; use Temporal\Workflow\ProcessInterface; +/** + * @implements CancellationScopeInterface + */ class Process extends Scope implements ProcessInterface { /** diff --git a/src/Internal/Workflow/Process/Scope.php b/src/Internal/Workflow/Process/Scope.php index 2e76ddf6..86eb8efe 100644 --- a/src/Internal/Workflow/Process/Scope.php +++ b/src/Internal/Workflow/Process/Scope.php @@ -13,7 +13,6 @@ use React\Promise\Deferred; use React\Promise\PromiseInterface; -use React\Promise\PromisorInterface; use Temporal\DataConverter\EncodedValues; use Temporal\DataConverter\ValuesInterface; use Temporal\Exception\DestructMemorizedInstanceException; @@ -34,9 +33,10 @@ * Unlike Java implementation, PHP merged coroutine and cancellation scope into single instance. * * @internal CoroutineScope is an internal library class, please do not use it in your code. - * @psalm-internal Temporal\Client + * @psalm-internal Temporal\Internal\Workflow + * @implements CancellationScopeInterface */ -class Scope implements CancellationScopeInterface, PromisorInterface +class Scope implements CancellationScopeInterface { /** * @var ServiceContainer @@ -272,11 +272,29 @@ public function promise(): PromiseInterface public function then( callable $onFulfilled = null, callable $onRejected = null, - callable $onProgress = null + callable $onProgress = null, ): PromiseInterface { - $promise = $this->deferred->promise(); + return $this->deferred->promise()->then($onFulfilled, $onRejected); + } + + public function catch(callable $onRejected): PromiseInterface + { + return $this->deferred->promise()->catch($onRejected); + } - return $promise->then($onFulfilled, $onRejected, $onProgress); + public function finally(callable $onFulfilledOrRejected): PromiseInterface + { + return $this->deferred->promise()->finally($onFulfilledOrRejected); + } + + public function otherwise(callable $onRejected): PromiseInterface + { + return $this->catch($onRejected); + } + + public function always(callable $onFulfilledOrRejected): PromiseInterface + { + return $this->finally($onFulfilledOrRejected); } /** @@ -386,7 +404,7 @@ protected function callSignalHandler(callable $handler): \Generator */ protected function onRequest(RequestInterface $request, PromiseInterface $promise): void { - $this->onCancel[++$this->cancelID] = function (\Throwable $reason = null) use ($request): void { + $this->onCancel[++$this->cancelID] = function (?\Throwable $reason = null) use ($request): void { if ($reason instanceof DestructMemorizedInstanceException) { // memory flush $this->context->getClient()->reject($request, $reason); @@ -442,7 +460,7 @@ protected function next(): void $this->nextPromise($current); break; - case $current instanceof PromisorInterface: + case $current instanceof Deferred: $this->nextPromise($current->promise()); break; @@ -504,7 +522,10 @@ function () use ($e): void { throw $e; }; - $promise->then($onFulfilled, $onRejected); + $promise + ->then($onFulfilled, $onRejected) + // Handle last error + ->then(null, fn (\Throwable $e) => null); } /** diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php index 82da41ae..ca29e769 100644 --- a/src/Internal/Workflow/WorkflowContext.php +++ b/src/Internal/Workflow/WorkflowContext.php @@ -509,7 +509,7 @@ public function resolveConditions(): void foreach ($this->awaits as $awaitsGroupId => $awaitsGroup) { foreach ($awaitsGroup as $i => [$condition, $deferred]) { if ($condition()) { - $deferred->resolve(); + $deferred->resolve(null); unset($this->awaits[$awaitsGroupId][$i]); $this->resolveConditionGroup($awaitsGroupId); } diff --git a/src/Promise.php b/src/Promise.php index 685b09fe..5c0f25cd 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -11,16 +11,16 @@ namespace Temporal; +use React\Promise\Exception\LengthException; +use React\Promise\Internal\RejectedPromise; use React\Promise\PromiseInterface; +use Temporal\Internal\Promise\CancellationQueue; + +use Temporal\Internal\Promise\Reasons; -use function React\Promise\all; -use function React\Promise\any; -use function React\Promise\map; -use function React\Promise\reduce; -use function React\Promise\some; -use function React\Promise\resolve; -use function React\Promise\reject; use function React\Promise\race; +use function React\Promise\reject; +use function React\Promise\resolve; /** * @psalm-type PromiseMapCallback = callable(mixed $value): mixed @@ -39,7 +39,7 @@ final class Promise */ public static function all(iterable $promises): PromiseInterface { - return all([...$promises]); + return self::map($promises, static fn($val): mixed => $val); } /** @@ -58,7 +58,8 @@ public static function all(iterable $promises): PromiseInterface */ public static function any(iterable $promises): PromiseInterface { - return any([...$promises]); + return self::some([...$promises], 1) + ->then(static fn(array $values): mixed => \array_shift($values)); } /** @@ -69,7 +70,7 @@ public static function any(iterable $promises): PromiseInterface * * The returned promise will reject if it becomes impossible for `$count` * items to resolve (that is, when `(count($promises) - $count) + 1` items - * reject). The rejection value will be an array of + * reject). The rejection value will be an iterable-exception of * `(count($promises) - $howMany) + 1` rejection reasons. * * The returned promise will also reject with a {@see LengthException} if @@ -81,7 +82,70 @@ public static function any(iterable $promises): PromiseInterface */ public static function some(iterable $promises, int $count): PromiseInterface { - return some([...$promises], $count); + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promises); + + return new \React\Promise\Promise( + static function (callable $resolve, callable $reject) use ($promises, $count, $cancellationQueue): void { + resolve($promises)->then( + static function (iterable $array) use ($count, $cancellationQueue, $resolve, $reject): void { + if (!\is_array($array) || $count < 1) { + $resolve([]); + return; + } + + $len = \count($array); + + if ($len < $count) { + $reject(new LengthException( + \sprintf( + 'Input array must contain at least %d item%s but contains only %s item%s.', + $count, + 1 === $count ? '' : 's', + $len, + 1 === $len ? '' : 's' + ) + )); + return; + } + + $toResolve = $count; + $toReject = ($len - $toResolve) + 1; + $values = []; + $reasons = []; + + foreach ($array as $i => $promiseOrValue) { + $fulfiller = static function (mixed $val) use ($i, &$values, &$toResolve, $toReject, $resolve): void { + if ($toResolve < 1 || $toReject < 1) { + return; + } + + $values[$i] = $val; + + if (0 === --$toResolve) { + $resolve($values); + } + }; + + $rejecter = static function (\Throwable $reason) use ($i, &$reasons, &$toReject, $toResolve, $reject): void { + if ($toResolve < 1 || $toReject < 1) { + return; + } + + $reasons[$i] = $reason; + + if (0 === --$toReject) { + $reject(new Reasons($reasons)); + } + }; + + $cancellationQueue->enqueue($promiseOrValue); + + resolve($promiseOrValue)->then($fulfiller, $rejecter); + } + }, $reject); + }, $cancellationQueue + ); } /** @@ -99,7 +163,41 @@ public static function some(iterable $promises, int $count): PromiseInterface */ public static function map(iterable $promises, callable $map): PromiseInterface { - return map([...$promises], $map); + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promises); + + return new \React\Promise\Promise( + function (callable $resolve, callable $reject) use ($promises, $map, $cancellationQueue): void { + resolve($promises) + ->then(static function (iterable $array) use ($map, $cancellationQueue, $resolve, $reject): void { + if (!\is_array($array) || !$array) { + $resolve([]); + return; + } + + $toResolve = \count($array); + $values = []; + + foreach ($array as $i => $promiseOrValue) { + $cancellationQueue->enqueue($promiseOrValue); + $values[$i] = null; + + resolve($promiseOrValue) + ->then($map) + ->then( + static function (mixed $mapped) use ($i, &$values, &$toResolve, $resolve): void { + $values[$i] = $mapped; + + if (0 === --$toResolve) { + $resolve($values); + } + }, + $reject, + ); + } + }, $reject); + }, $cancellationQueue + ); } /** @@ -110,13 +208,61 @@ public static function map(iterable $promises, callable $map): PromiseInterface * * @psalm-param PromiseReduceCallback $reduce * @param iterable $promises - * @param callable $reduce + * @param callable(mixed $current, mixed $carry, int $current, positive-int $items): mixed $reduce * @param mixed $initial * @return PromiseInterface */ public static function reduce(iterable $promises, callable $reduce, $initial = null): PromiseInterface { - return reduce([...$promises], $reduce, $initial); + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promises); + + return new \React\Promise\Promise( + function (callable $resolve, callable $reject) use ($promises, $reduce, $initial, $cancellationQueue): void { + resolve($promises) + ->then( + static function (iterable $array) use ( + $reduce, + $initial, + $cancellationQueue, + $resolve, + $reject, + ): void { + if (!\is_array($array)) { + $array = []; + } + + $total = \count($array); + $i = 0; + + // Wrap the supplied $reduce with one that handles promises and then + // delegates to the supplied. + $wrappedReduceFunc = static function (PromiseInterface $current, mixed $val) use ( + $reduce, + $cancellationQueue, + $total, + &$i + ): PromiseInterface { + $cancellationQueue->enqueue($val); + + return $current + ->then(static function (mixed $c) use ($reduce, $total, &$i, $val): PromiseInterface { + return resolve($val) + ->then(static function (mixed $value) use ($reduce, $total, &$i, $c): mixed { + return $reduce($c, $value, $i++, $total); + }); + }); + }; + + $cancellationQueue->enqueue($initial); + + \array_reduce($array, $wrappedReduceFunc, resolve($initial)) + ->then($resolve, $reject); + }, + $reject, + ); + }, $cancellationQueue + ); } /** @@ -152,7 +298,7 @@ public static function resolve($promiseOrValue = null): PromiseInterface * the value of another promise. * * @param $promiseOrValue - * @return PromiseInterface + * @return PromiseInterface */ public static function reject($promiseOrValue = null): PromiseInterface { @@ -166,8 +312,9 @@ public static function reject($promiseOrValue = null): PromiseInterface * The returned promise will become **infinitely pending** if `$promisesOrValues` * contains 0 items. * - * @param iterable $promisesOrValues - * @return PromiseInterface + * @template T + * @param iterable|T> $promisesOrValues + * @return PromiseInterface */ public static function race(iterable $promisesOrValues): PromiseInterface { diff --git a/src/Workflow.php b/src/Workflow.php index 24426a35..3bedb197 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -27,7 +27,6 @@ use Temporal\Workflow\ChildWorkflowStubInterface; use Temporal\Workflow\ContinueAsNewOptions; use Temporal\Workflow\ExternalWorkflowStubInterface; -use Temporal\Workflow\ParentClosePolicy; use Temporal\Workflow\ScopedContextInterface; use Temporal\Internal\Workflow\WorkflowContext; use Temporal\Workflow\WorkflowExecution; @@ -318,7 +317,7 @@ public static function await(...$conditions): PromiseInterface * * @param DateIntervalValue $interval * @param callable|PromiseInterface ...$conditions - * @return PromiseInterface + * @return PromiseInterface */ public static function awaitWithTimeout($interval, ...$conditions): PromiseInterface { @@ -424,7 +423,7 @@ public static function registerSignal(string $queryType, callable $handler): Sco * @param string $changeId * @param int $minSupported * @param int $maxSupported - * @return PromiseInterface + * @return PromiseInterface * @throws OutOfContextException in the absence of the workflow execution context. */ public static function getVersion(string $changeId, int $minSupported, int $maxSupported): PromiseInterface @@ -453,8 +452,9 @@ public static function getVersion(string $changeId, int $minSupported, int $maxS * } * * - * @param callable $value - * @return PromiseInterface + * @template TReturn + * @param callable(): TReturn $value + * @return PromiseInterface * @throws OutOfContextException in the absence of the workflow execution context. */ public static function sideEffect(callable $value): PromiseInterface @@ -489,7 +489,7 @@ public static function sideEffect(callable $value): PromiseInterface * * * @param DateIntervalValue $interval - * @return PromiseInterface + * @return PromiseInterface * @throws OutOfContextException in the absence of the workflow execution context. */ public static function timer($interval): PromiseInterface @@ -830,7 +830,7 @@ public static function newUntypedExternalWorkflowStub(WorkflowExecution $executi * @param array $args * @param ActivityOptions|null $options * @param Type|string|null|\ReflectionClass|\ReflectionType $returnType - * @return PromiseInterface + * @return PromiseInterface * @throws OutOfContextException in the absence of the workflow execution context. */ public static function executeActivity( @@ -946,7 +946,7 @@ public static function upsertSearchAttributes(array $searchAttributes): void /** * Generate a UUID. * - * @return PromiseInterface + * @return PromiseInterface */ public static function uuid(): PromiseInterface { @@ -959,7 +959,7 @@ public static function uuid(): PromiseInterface /** * Generate a UUID version 4 (random). * - * @return PromiseInterface + * @return PromiseInterface */ public static function uuid4(): PromiseInterface { @@ -976,7 +976,7 @@ public static function uuid4(): PromiseInterface * to create the version 7 UUID. If not provided, the UUID is generated * using the current date/time. * - * @return PromiseInterface + * @return PromiseInterface */ public static function uuid7(?DateTimeInterface $dateTime = null): PromiseInterface { diff --git a/src/Workflow/CancellationScopeInterface.php b/src/Workflow/CancellationScopeInterface.php index ecc7f612..adf75658 100644 --- a/src/Workflow/CancellationScopeInterface.php +++ b/src/Workflow/CancellationScopeInterface.php @@ -11,10 +11,13 @@ namespace Temporal\Workflow; -use React\Promise\CancellablePromiseInterface; use React\Promise\PromiseInterface; -interface CancellationScopeInterface extends PromiseInterface, CancellablePromiseInterface +/** + * @template T + * @extends PromiseInterface + */ +interface CancellationScopeInterface extends PromiseInterface { /** * Detached scopes can continue working even if parent scope was cancelled. @@ -37,4 +40,15 @@ public function isCancelled(): bool; * @return $this */ public function onCancel(callable $then): self; + + /** + * The `cancel()` method notifies the creator of the promise that there is no + * further interest in the results of the operation. + * + * Once a promise is settled (either fulfilled or rejected), calling `cancel()` on + * a promise has no effect. + * + * @return void + */ + public function cancel(): void; } diff --git a/src/Workflow/ProcessInterface.php b/src/Workflow/ProcessInterface.php index 29baaa3d..e35d2787 100644 --- a/src/Workflow/ProcessInterface.php +++ b/src/Workflow/ProcessInterface.php @@ -13,6 +13,10 @@ use Temporal\Internal\Repository\Identifiable; +/** + * @template T + * @extends CancellationScopeInterface + */ interface ProcessInterface extends CancellationScopeInterface, Identifiable { /** diff --git a/src/Workflow/WorkflowContextInterface.php b/src/Workflow/WorkflowContextInterface.php index bf8e150d..800bc540 100644 --- a/src/Workflow/WorkflowContextInterface.php +++ b/src/Workflow/WorkflowContextInterface.php @@ -94,11 +94,9 @@ public function getVersion(string $changeId, int $minSupported, int $maxSupporte /** * @see Workflow::sideEffect() * - * @psalm-type SideEffectCallback = callable(): mixed - * @psalm-param SideEffectCallback $context - * - * @param callable $context - * @return PromiseInterface + * @template TReturn + * @param callable(): TReturn $context + * @return PromiseInterface */ public function sideEffect(callable $context): PromiseInterface; @@ -223,7 +221,7 @@ public function newUntypedExternalWorkflowStub(WorkflowExecution $execution): Ex * @param array $args * @param ActivityOptions|null $options * @param Type|string|null|\ReflectionClass|\ReflectionType $returnType - * @return PromiseInterface + * @return PromiseInterface */ public function executeActivity( string $type, @@ -266,7 +264,7 @@ public function await(...$conditions): PromiseInterface; * * @param DateIntervalValue $interval * @param callable|PromiseInterface ...$conditions - * @return PromiseInterface + * @return PromiseInterface */ public function awaitWithTimeout($interval, ...$conditions): PromiseInterface; @@ -287,7 +285,7 @@ public function upsertSearchAttributes(array $searchAttributes): void; * * Generate a UUID. * - * @return PromiseInterface + * @return PromiseInterface */ public function uuid(): PromiseInterface; @@ -296,7 +294,7 @@ public function uuid(): PromiseInterface; * * Generate a UUID version 4 (random). * - * @return PromiseInterface + * @return PromiseInterface */ public function uuid4(): PromiseInterface; @@ -309,7 +307,7 @@ public function uuid4(): PromiseInterface; * to create the version 7 UUID. If not provided, the UUID is generated * using the current date/time. * - * @return PromiseInterface + * @return PromiseInterface */ public function uuid7(?DateTimeInterface $dateTime = null): PromiseInterface; } diff --git a/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php b/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php index 0b72eb53..bb277eb4 100644 --- a/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php +++ b/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php @@ -44,7 +44,7 @@ function () use ($simple, $waitSignalled) { yield $simple->add(8); $this->status[] = 'child signalled'; - $waitSignalled->resolve(); + $waitSignalled->resolve(null); return yield $call; } diff --git a/tests/Unit/Promise/BaseFunction.php b/tests/Unit/Promise/BaseFunction.php new file mode 100644 index 00000000..decd7e93 --- /dev/null +++ b/tests/Unit/Promise/BaseFunction.php @@ -0,0 +1,75 @@ +createCallableMock(); + $mock + ->expects($this->exactly($amount)) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnce() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableNever(): callable + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + return $mock; + } + + protected function createCallableMock() + { + return $this->getMockBuilder('stdClass')->addMethods(array('__invoke'))->getMock(); + } + + protected function setExpectedException($exception, $exceptionMessage = '', $exceptionCode = null) + { + $this->expectException($exception); + if ($exceptionMessage !== '') { + $this->expectExceptionMessage($exceptionMessage); + } + if ($exceptionCode !== null) { + $this->expectExceptionCode($exceptionCode); + } + } + + protected function creteCancellableMock(): MockObject + { + return \interface_exists(CancellablePromiseInterface::class) + ? $this + ->getMockBuilder(CancellablePromiseInterface::class) + ->getMock() + : $this + ->getMockBuilder(PromiseInterface::class) + ->getMock(); + } +} diff --git a/tests/Unit/Promise/FunctionAllTestCase.php b/tests/Unit/Promise/FunctionAllTestCase.php new file mode 100644 index 00000000..7944f081 --- /dev/null +++ b/tests/Unit/Promise/FunctionAllTestCase.php @@ -0,0 +1,106 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([])); + + Promise::all([]) + ->then($mock); + } + + public function testResolveValuesArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2, 3])); + + Promise::all([1, 2, 3]) + ->then($mock); + } + + public function testResolvePromisesArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2, 3])); + + Promise::all([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)]) + ->then($mock); + } + + public function testResolveSparseArrayInput(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([null, 1, null, 1, 1])); + + Promise::all([null, 1, null, 1, 1]) + ->then($mock); + } + + public function testRejectIfAnyInputPromiseRejects(): void + { + $e = new \Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + Promise::all([Promise::resolve(1), Promise::reject($e), Promise::resolve(3)]) + ->then($this->expectCallableNever(), $mock); + } + + public function testAcceptAPromiseForAnArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2, 3])); + + Promise::all([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)]) + ->then($mock); + } + + public function testPreserveTheOrderOfArrayWhenResolvingAsyncPromises(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2, 3])); + + $deferred = new Deferred(); + + Promise::all([Promise::resolve(1), $deferred->promise(), Promise::resolve(3)]) + ->then($mock); + + $deferred->resolve(2); + } +} diff --git a/tests/Unit/Promise/FunctionAnyTestCase.php b/tests/Unit/Promise/FunctionAnyTestCase.php new file mode 100644 index 00000000..e7e6792d --- /dev/null +++ b/tests/Unit/Promise/FunctionAnyTestCase.php @@ -0,0 +1,166 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with( + $this->callback(function ($exception) { + return $exception instanceof LengthException && + 'Input array must contain at least 1 item but contains only 0 items.' === $exception->getMessage(); + }) + ); + + Promise::any([]) + ->then($this->expectCallableNever(), $mock); + } + + public function testResolveWithAnInputValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + Promise::any([1, 2, 3]) + ->then($mock); + } + + public function testResolveWithAPromisedInputValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + Promise::any([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)]) + ->then($mock); + } + + public function testRejectWithAllRejectedInputValuesIfAllInputsAreRejected(): void + { + $e1 = new \Exception(); + $e2 = new \Exception(); + $e3 = new \Exception(); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->callback(fn (Reasons $e): bool => \iterator_to_array($e) === [0 => $e1, 1 => $e2, 2 => $e3])); + + Promise::any([Promise::reject($e1), Promise::reject($e2), Promise::reject($e3)]) + ->then($this->expectCallableNever(), $mock); + } + + public function testResolveWhenFirstInputPromiseResolves(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + Promise::any([Promise::resolve(1), Promise::reject(new \Exception()), Promise::reject(new \Exception())]) + ->then($mock); + } + + public function testNotRelyOnArryIndexesWhenUnwrappingToASingleResolutionValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(2)); + + $d1 = new Deferred(); + $d2 = new Deferred(); + + Promise::any(['abc' => $d1->promise(), 1 => $d2->promise()]) + ->then($mock); + + $d2->resolve(2); + $d1->resolve(1); + } + + public function testRejectWhenInputPromiseRejects(): void + { + $e = new \Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + // ->with($this->identicalTo(null)); + ->with($this->callback(fn (Reasons $reason): bool => \iterator_to_array($reason) === [$e])); + + Promise::any([Promise::reject($e)]) + ->then($this->expectCallableNever(), $mock) + ->then(null, fn(\Throwable $e) => null); + } + + public function testCancelInputPromise(): void + { + $mock = $this->creteCancellableMock(); + $mock + ->expects($this->once()) + ->method('cancel'); + + Promise::any([$mock])->cancel(); + } + + public function testCancelInputArrayPromises(): void + { + $mock1 = $this->creteCancellableMock(); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->creteCancellableMock(); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + Promise::any([$mock1, $mock2])->cancel(); + } + + public function testNotCancelOtherPendingInputArrayPromisesIfOnePromiseFulfills(): void + { + $e = new \Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + + $deferred = new Deferred($mock); + $deferred->resolve($e); + + $mock2 = $this->creteCancellableMock(); + $mock2 + ->expects($this->never()) + ->method('cancel'); + + Promise::some([$deferred->promise(), $mock2], 1)->cancel(); + } +} diff --git a/tests/Unit/Promise/FunctionMapTestCase.php b/tests/Unit/Promise/FunctionMapTestCase.php new file mode 100644 index 00000000..21f3c743 --- /dev/null +++ b/tests/Unit/Promise/FunctionMapTestCase.php @@ -0,0 +1,164 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([2, 4, 6])); + + Promise::map( + [1, 2, 3], + $this->mapper() + )->then($mock); + } + + public function testMapInputPromisesArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([2, 4, 6])); + + Promise::map( + [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)], + $this->mapper() + )->then($mock); + } + + public function testMapMixedInputArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([2, 4, 6])); + + Promise::map( + [1, Promise::resolve(2), 3], + $this->mapper() + )->then($mock); + } + + public function testMapInputWhenMapperReturnsAPromise(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([2, 4, 6])); + + Promise::map( + [1, 2, 3], + $this->promiseMapper() + )->then($mock); + } + + public function testPreserveTheOrderOfArrayWhenResolvingAsyncPromises(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([2, 4, 6])); + + $deferred = new Deferred(); + + Promise::map( + [Promise::resolve(1), $deferred->promise(), Promise::resolve(3)], + $this->mapper() + )->then($mock); + + $deferred->resolve(2); + } + + public function testRejectWhenInputContainsRejection(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + Promise::map( + [Promise::resolve(1), Promise::reject($e), Promise::resolve(3)], + $this->mapper() + )->then($this->expectCallableNever(), $mock); + } + + public function testRejectWhenInputPromiseRejects(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + Promise::map( + [Promise::reject($e)], + $this->mapper() + )->then($this->expectCallableNever(), $mock); + } + + public function testCancelInputPromise(): void + { + $mock = $this->creteCancellableMock(); + $mock + ->expects($this->once()) + ->method('cancel'); + + Promise::map( + [$mock], + $this->mapper() + )->cancel(); + } + + public function testCancelInputArrayPromises(): void + { + $mock1 = $this + ->creteCancellableMock(); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this + ->creteCancellableMock(); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + Promise::map( + [$mock1, $mock2], + $this->mapper() + )->cancel(); + } + + protected function mapper(): callable + { + return static fn($val) => $val * 2; + } + + protected function promiseMapper(): callable + { + return static fn($val) => Promise::resolve($val * 2); + } +} diff --git a/tests/Unit/Promise/FunctionReduceTestCase.php b/tests/Unit/Promise/FunctionReduceTestCase.php new file mode 100644 index 00000000..9ebe9499 --- /dev/null +++ b/tests/Unit/Promise/FunctionReduceTestCase.php @@ -0,0 +1,301 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(6)); + + Promise::reduce( + [1, 2, 3], + $this->plus() + )->then($mock); + } + + public function testReduceValuesWithInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(7)); + + Promise::reduce( + [1, 2, 3], + $this->plus(), + 1 + )->then($mock); + } + + public function testReduceValuesWithInitialPromise(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(7)); + + Promise::reduce( + [1, 2, 3], + $this->plus(), + Promise::resolve(1) + )->then($mock); + } + + public function testReducePromisedValuesWithoutInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(6)); + + Promise::reduce( + [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)], + $this->plus() + )->then($mock); + } + + public function testReducePromisedValuesWithInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(7)); + + Promise::reduce( + [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)], + $this->plus(), + 1 + )->then($mock); + } + + public function testReducePromisedValuesWithInitialPromise(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(7)); + + Promise::reduce( + [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)], + $this->plus(), + Promise::resolve(1) + )->then($mock); + } + + public function testReduceEmptyInputWithInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + Promise::reduce( + [], + $this->plus(), + 1 + )->then($mock); + } + + public function testReduceEmptyInputWithInitialPromise(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + Promise::reduce( + [], + $this->plus(), + Promise::resolve(1) + )->then($mock); + } + + public function testRejectWhenInputContainsRejection(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + Promise::reduce( + [Promise::resolve(1), Promise::reject($e), Promise::resolve(3)], + $this->plus(), + Promise::resolve(1) + )->then($this->expectCallableNever(), $mock); + } + + public function testResolveWithNullWhenInputIsEmptyAndNoInitialValueOrPromiseProvided(): void + { + // Note: this is different from when.js's behavior! + // In when.Promise::reduce(), this rejects with a TypeError exception (following + // JavaScript's [].reduce behavior. + // We're following PHP's array_reduce behavior and resolve with NULL. + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + Promise::reduce( + [], + $this->plus() + )->then($mock); + } + + public function testAllowSparseArrayInputWithoutInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(3)); + + Promise::reduce( + [null, null, 1, null, 1, 1], + $this->plus() + )->then($mock); + } + + public function testAllowSparseArrayInputWithInitialValue(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(4)); + + Promise::reduce( + [null, null, 1, null, 1, 1], + $this->plus(), + 1 + )->then($mock); + } + + public function testReduceInInputOrder(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo('123')); + + Promise::reduce( + [1, 2, 3], + $this->append(), + '' + )->then($mock); + } + + public function testProvideCorrectBasisValue(): void + { + $insertIntoArray = function ($arr, $val, $i) { + $arr[$i] = $val; + + return $arr; + }; + + $d1 = new Deferred(); + $d2 = new Deferred(); + $d3 = new Deferred(); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2, 3])); + + Promise::reduce( + [$d1->promise(), $d2->promise(), $d3->promise()], + $insertIntoArray, + [] + )->then($mock); + + $d3->resolve(3); + $d1->resolve(1); + $d2->resolve(2); + } + + public function testRejectWhenInputPromiseRejects(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + Promise::reduce( + [Promise::reject($e)], + $this->plus(), + 1 + )->then($this->expectCallableNever(), $mock); + } + + public function testCancelInputPromise(): void + { + $mock = $this->creteCancellableMock(); + $mock + ->expects($this->once()) + ->method('cancel'); + + Promise::reduce( + [$mock], + $this->plus(), + 1 + )->cancel(); + } + + public function testCancelInputArrayPromises(): void + { + $mock1 = $this->creteCancellableMock(); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->creteCancellableMock(); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + Promise::reduce( + [$mock1, $mock2], + $this->plus(), + 1 + )->cancel(); + } + + protected function plus(): callable + { + return static fn($sum, $val) => $sum + $val; + } + + protected function append(): callable + { + return static fn($sum, $val) => $sum . $val; + } +} diff --git a/tests/Unit/Promise/FunctionSomeTestCase.php b/tests/Unit/Promise/FunctionSomeTestCase.php new file mode 100644 index 00000000..0d98abf0 --- /dev/null +++ b/tests/Unit/Promise/FunctionSomeTestCase.php @@ -0,0 +1,186 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with( + $this->callback(static function ($exception): bool { + return $exception instanceof LengthException && + 'Input array must contain at least 1 item but contains only 0 items.' === $exception->getMessage(); + }) + ); + + Promise::some([], 1) + ->then($this->expectCallableNever(), $mock); + } + + public function testRejectWithLengthExceptionWithInputArrayContainingNotEnoughItems(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with( + $this->callback(function ($exception) { + return $exception instanceof LengthException && + 'Input array must contain at least 4 items but contains only 3 items.' === $exception->getMessage(); + }) + ); + + Promise::some( + [1, 2, 3], + 4 + )->then($this->expectCallableNever(), $mock); + } + + public function testResolveValuesArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2])); + + Promise::some( + [1, 2, 3], + 2 + )->then($mock); + } + + public function testResolvePromisesArray(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([1, 2])); + + Promise::some( + [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)], + 2 + )->then($mock); + } + + public function testResolveSparseArrayInput(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([null, 1])); + + Promise::some( + [null, 1, null, 2, 3], + 2 + )->then($mock); + } + + public function testRejectIfAnyInputPromiseRejectsBeforeDesiredNumberOfInputsAreResolved(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->callback(function (mixed $exception) use ($e) { + return $exception instanceof Reasons && + \in_array($e, \iterator_to_array($exception)); + })); + + Promise::some( + [Promise::resolve(1), Promise::reject($e), Promise::reject($e)], + 2 + )->then($this->expectCallableNever(), $mock); + } + + public function testResolveWithEmptyArrayIfHowManyIsLessThanOne(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo([])); + + Promise::some( + [1], + 0 + )->then($mock); + } + + public function testCancelInputArrayPromises(): void + { + $mock1 = $this + ->creteCancellableMock(); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this + ->creteCancellableMock(); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + Promise::some([$mock1, $mock2], 1)->cancel(); + } + + public function testNotCancelOtherPendingInputArrayPromisesIfEnoughPromisesFulfill(): void + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + $deferred = new Deferred($mock); + $deferred->resolve(null); + + $mock2 = $this + ->creteCancellableMock(); + $mock2 + ->expects($this->never()) + ->method('cancel'); + + Promise::some([$deferred->promise(), $mock2], 1); + } + + public function testNotCancelOtherPendingInputArrayPromisesIfEnoughPromisesReject(): void + { + $e = new Exception(); + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + $deferred = new Deferred($mock); + $deferred->reject($e); + + $mock2 = $this + ->creteCancellableMock(); + $mock2 + ->expects($this->never()) + ->method('cancel'); + + Promise::some([$deferred->promise(), $mock2], 2); + } +}