Skip to content

Commit 6ba0ab7

Browse files
committed
waitUntil implementation for the reactphp webdriver (based on loop timers)
1 parent 217af76 commit 6ba0ab7

4 files changed

Lines changed: 222 additions & 44 deletions

File tree

src/Client/W3CClient.php

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ function (OptionsResolver $serverOptionsResolver) {
116116
*/
117117
public function getSessionIdentifiers(): PromiseInterface
118118
{
119-
// todo
119+
// todo: implementation
120120

121121
return reject(new RuntimeException('Not implemented.'));
122122
}
@@ -184,7 +184,7 @@ function (Throwable $rejectionReason) use ($sessionOpeningDeferred) {
184184
*/
185185
public function removeSession(string $sessionIdentifier): PromiseInterface
186186
{
187-
// TODO: Implement removeSession() method.
187+
// todo: implementation
188188

189189
return reject(new RuntimeException('Not implemented.'));
190190
}
@@ -555,7 +555,7 @@ function (Throwable $rejectionReason) {
555555
*/
556556
public function clickElement(string $sessionIdentifier, array $elementIdentifier): PromiseInterface
557557
{
558-
// TODO: Implement clickElement() method.
558+
// todo: implementation
559559

560560
return reject(new RuntimeException('Not implemented.'));
561561
}
@@ -773,6 +773,33 @@ private function requestMouseActions(string $sessionIdentifier, array $mouseActi
773773
return $responsePromise;
774774
}
775775

776+
/**
777+
* Returns an element identifier, which has to be extracted from the response message (a surgical approach)
778+
*
779+
* @param ResponseInterface $response PSR-7 response message from the Selenium hub with action results
780+
*
781+
* @return array
782+
*/
783+
private function extractElementIdentifier(ResponseInterface $response): array
784+
{
785+
$responseBody = (string) $response->getBody();
786+
787+
preg_match(
788+
'/(element(?:-[a-z\d]{4}){4}[a-z\d]{8})[":\s]+([a-z\d]{8}(?:-[a-z\d]{4}){4}[a-z\d]{8})/Ui',
789+
$responseBody,
790+
$matches
791+
);
792+
793+
if (!isset($matches[1], $matches[2])) {
794+
// todo: locate an error message or set it as "undefined error"
795+
throw new RuntimeException('Unable to locate element identifier parts in the response.');
796+
}
797+
798+
$elementIdentifier = [$matches[1] => $matches[2]];
799+
800+
return $elementIdentifier;
801+
}
802+
776803
/**
777804
* Ensures that a related action is properly executed (confirmed) by the remote server, triggers an error otherwise.
778805
*
@@ -815,31 +842,4 @@ private function deserializeResponse(ResponseInterface $response)
815842

816843
return $bodyDeserialized['value'];
817844
}
818-
819-
/**
820-
* Returns an element identifier, which has to be extracted from the response message (a surgical approach)
821-
*
822-
* @param ResponseInterface $response PSR-7 response message from the Selenium hub with action results
823-
*
824-
* @return array
825-
*/
826-
private function extractElementIdentifier(ResponseInterface $response): array
827-
{
828-
$responseBody = (string) $response->getBody();
829-
830-
preg_match(
831-
'/(element(?:-[a-z\d]{4}){4}[a-z\d]{8})[":\s]+([a-z\d]{8}(?:-[a-z\d]{4}){4}[a-z\d]{8})/Ui',
832-
$responseBody,
833-
$matches
834-
);
835-
836-
if (!isset($matches[1], $matches[2])) {
837-
// todo: locate an error message or set it as "undefined error"
838-
throw new RuntimeException('Unable to locate element identifier parts in the response.');
839-
}
840-
841-
$elementIdentifier = [$matches[1] => $matches[2]];
842-
843-
return $elementIdentifier;
844-
}
845845
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the ReactPHP WebDriver <https://github.com/itnelo/reactphp-webdriver>.
5+
*
6+
* (c) 2020 Pavel Petrov <itnelo@gmail.com>.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @license https://opensource.org/licenses/mit MIT
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace Itnelo\React\WebDriver\Routine\Condition;
17+
18+
use Itnelo\React\WebDriver\Timeout\Interceptor as TimeoutInterceptor;
19+
use LogicException;
20+
use React\EventLoop\LoopInterface;
21+
use React\EventLoop\TimerInterface;
22+
use React\Promise\Deferred;
23+
use React\Promise\PromiseInterface;
24+
use RuntimeException;
25+
use Throwable;
26+
27+
/**
28+
* Runs periodic condition checks for the web driver, using the specified event loop instance
29+
*
30+
* @internal
31+
*/
32+
final class CheckRoutine
33+
{
34+
/**
35+
* Event loop reference
36+
*
37+
* @var LoopInterface
38+
*/
39+
private LoopInterface $loop;
40+
41+
/**
42+
* Cancels a driver promise if it isn't resolved for too long
43+
*
44+
* @var TimeoutInterceptor
45+
*/
46+
private TimeoutInterceptor $timeoutInterceptor;
47+
48+
/**
49+
* A timer, registered in the event loop, which executes routine logic
50+
*
51+
* @var TimerInterface|null
52+
*/
53+
private ?TimerInterface $_evaluationTimer;
54+
55+
/**
56+
* A flag that is set, when the routine already waits promise result from the next check iteration
57+
*
58+
* @var bool
59+
*/
60+
private bool $_isEvaluating;
61+
62+
/**
63+
* CheckRoutine constructor.
64+
*
65+
* @param LoopInterface $loop Event loop reference
66+
* @param TimeoutInterceptor $timeoutInterceptor Cancels a driver promise if it isn't resolved for too long
67+
*/
68+
public function __construct(LoopInterface $loop, TimeoutInterceptor $timeoutInterceptor)
69+
{
70+
$this->loop = $loop;
71+
$this->timeoutInterceptor = $timeoutInterceptor;
72+
73+
$this->_evaluationTimer = null;
74+
$this->_isEvaluating = false;
75+
}
76+
77+
/**
78+
* Runs a routine logic in the loop timer and returns a promise, which will be resolved when the condition is met
79+
*
80+
* @param callable $conditionMetCallback A condition to be met, as a callback
81+
* @param float $checkInterval The interval for condition checks, in seconds
82+
*
83+
* @return PromiseInterface<null>
84+
*
85+
* @throws RuntimeException Whenever a routine is already is the running state
86+
*/
87+
public function run(callable $conditionMetCallback, float $checkInterval = 0.5): PromiseInterface
88+
{
89+
if ($this->_evaluationTimer instanceof TimerInterface) {
90+
throw new RuntimeException('Routine is already running.');
91+
}
92+
93+
return $this->runInternal($conditionMetCallback, $checkInterval);
94+
}
95+
96+
/**
97+
* Returns a promise that will be resolved when the check routine successfully evaluates a given callback
98+
*
99+
* @param callable $conditionMetCallback A condition to be met, as a callback
100+
* @param float $checkInterval The interval for condition checks, in seconds
101+
*
102+
* @return PromiseInterface<null>
103+
*/
104+
private function runInternal(callable $conditionMetCallback, float $checkInterval): PromiseInterface
105+
{
106+
$evaluationDeferred = new Deferred();
107+
108+
$evaluationLogic = function () use ($evaluationDeferred, $conditionMetCallback) {
109+
// do not try to evaluate a condition if a previous result promise was not resolved/rejected.
110+
if (false !== $this->_isEvaluating) {
111+
return;
112+
}
113+
114+
try {
115+
// receiving a new result promise from the condition callback.
116+
$resultPromise = $conditionMetCallback();
117+
118+
if (!$resultPromise instanceof PromiseInterface) {
119+
$invalidPromiseExceptionMessage = sprintf(
120+
'Return value from the condition callable must be an instance of %s.',
121+
PromiseInterface::class
122+
);
123+
124+
throw new LogicException($invalidPromiseExceptionMessage);
125+
}
126+
127+
// handling evaluation results.
128+
$this->_isEvaluating = true;
129+
$resultPromise->then(
130+
function () use ($evaluationDeferred) {
131+
// at some point, a promise has been successfully resolved.
132+
$evaluationDeferred->resolve(null);
133+
},
134+
function (Throwable $rejectionReason) {
135+
// signals that we can take another promise from the condition callback to continue our checks.
136+
$this->_isEvaluating = false;
137+
}
138+
);
139+
} catch (Throwable $exception) {
140+
$reason = new RuntimeException('Unable to evaluate a condition callback.', 0, $exception);
141+
142+
$evaluationDeferred->reject($reason);
143+
}
144+
};
145+
146+
// registering a periodic timer in the event loop.
147+
$this->_evaluationTimer = $this->loop->addPeriodicTimer($checkInterval, $evaluationLogic);
148+
149+
$conditionMetPromise = $evaluationDeferred->promise();
150+
151+
$conditionMetTimedPromise = $this->timeoutInterceptor->applyTimeout(
152+
$conditionMetPromise,
153+
'A condition is not met within the specified amount of time.'
154+
);
155+
156+
return $conditionMetTimedPromise->then(
157+
function () {
158+
// cleaning up a related timer with condition-check logic.
159+
$this->loop->cancelTimer($this->_evaluationTimer);
160+
161+
return null;
162+
},
163+
function (Throwable $rejectionReason) {
164+
$this->loop->cancelTimer($this->_evaluationTimer);
165+
166+
throw $rejectionReason;
167+
}
168+
);
169+
}
170+
}

src/SeleniumHubDriver.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
namespace Itnelo\React\WebDriver;
1717

18+
use Itnelo\React\WebDriver\Routine\Condition\CheckRoutine as ConditionCheckRoutine;
1819
use Itnelo\React\WebDriver\Timeout\Interceptor as TimeoutInterceptor;
1920
use React\EventLoop\LoopInterface;
2021
use React\Promise\Deferred;
@@ -103,7 +104,7 @@ public function createSession(): PromiseInterface
103104
*/
104105
public function getSessionIdentifiers(): PromiseInterface
105106
{
106-
// TODO: Implement getSessionIdentifiers() method.
107+
// todo: implementation
107108

108109
return reject(new RuntimeException('Not implemented.'));
109110
}
@@ -113,7 +114,7 @@ public function getSessionIdentifiers(): PromiseInterface
113114
*/
114115
public function removeSession(string $sessionIdentifier): PromiseInterface
115116
{
116-
// TODO: Implement removeSession() method.
117+
// todo: implementation
117118

118119
return reject(new RuntimeException('Not implemented.'));
119120
}
@@ -234,7 +235,7 @@ public function getElementVisibility(string $sessionIdentifier, array $elementId
234235
*/
235236
public function clickElement(string $sessionIdentifier, array $elementIdentifier): PromiseInterface
236237
{
237-
// TODO: Implement clickElement() method.
238+
// todo: implementation
238239

239240
return reject(new RuntimeException('Not implemented.'));
240241
}
@@ -299,7 +300,7 @@ public function mouseLeftClick(string $sessionIdentifier): PromiseInterface
299300
/**
300301
* {@inheritDoc}
301302
*/
302-
public function wait(float $time): PromiseInterface
303+
public function wait(float $time = 30.0): PromiseInterface
303304
{
304305
$idlePromise = resolve($time, $this->loop);
305306

@@ -309,11 +310,15 @@ public function wait(float $time): PromiseInterface
309310
/**
310311
* {@inheritDoc}
311312
*/
312-
public function waitUntil(float $time, callable $conditionMetCallback): PromiseInterface
313+
public function waitUntil(callable $conditionMetCallback, float $time = 30.0): PromiseInterface
313314
{
314-
// TODO: Implement waitUntil() method.
315+
$timeNormalized = max(0.5, $time);
315316

316-
return reject(new RuntimeException('Not implemented.'));
317+
// todo: probably, should be redesigned, to reduce amount of "new" calls
318+
$timeoutInterceptor = new TimeoutInterceptor($this->loop, $timeNormalized);
319+
$checkRoutine = new ConditionCheckRoutine($this->loop, $timeoutInterceptor);
320+
321+
return $checkRoutine->run($conditionMetCallback);
317322
}
318323

319324
/**

src/WebDriverInterface.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,22 @@ interface WebDriverInterface extends ClientInterface
6666
*
6767
* @return PromiseInterface<null>
6868
*/
69-
public function wait(float $time): PromiseInterface;
69+
public function wait(float $time = 30.0): PromiseInterface;
7070

7171
/**
72-
* Returns a promise that will be resolved when a given condition is met within specified amount of time and
72+
* Returns a promise that will be resolved, when a given condition is met within specified amount of time, and
7373
* rejected, otherwise.
7474
*
7575
* A condition callback must return an instance of PromiseInterface. Whenever that promise becomes rejected, driver
7676
* will try to get a new promise from the callback, until it reaches a given timeout for retry attempts.
7777
*
78+
* Note: waitUntil itself doesn't apply any timeouts for a single result promise (check iteration), the user-side
79+
* MUST control any kind of promise timeouts for the condition callback, which is supplied to the method.
80+
*
7881
* Usage example:
7982
*
8083
* ```
8184
* $becomeVisiblePromise = $webDriver->waitUntil(
82-
* 15.5,
8385
* function () use ($webDriver) {
8486
* $visibilityStatePromise = $webDriver->getElementVisibility(...);
8587
*
@@ -90,7 +92,8 @@ public function wait(float $time): PromiseInterface;
9092
* }
9193
* }
9294
* );
93-
* }
95+
* },
96+
* 15.5
9497
* );
9598
*
9699
* $becomeVisiblePromise->then(
@@ -102,13 +105,13 @@ public function wait(float $time): PromiseInterface;
102105
* );
103106
* ```
104107
*
105-
* @param float $time Time (in seconds) to wait for successfully resolved promise from the
106-
* condition callback
107108
* @param callable $conditionMetCallback A condition to be met, as a callback
109+
* @param float $time Time (in seconds) to wait for successfully resolved promise from the
110+
* condition callback (minimum: 0.5)
108111
*
109112
* @return PromiseInterface<null>
110113
*/
111-
public function waitUntil(float $time, callable $conditionMetCallback): PromiseInterface;
114+
public function waitUntil(callable $conditionMetCallback, float $time = 30.0): PromiseInterface;
112115

113116
/**
114117
* Returns a promise that will be resolved if a screenshot is successfully received and saved using the specified

0 commit comments

Comments
 (0)