Skip to content

Commit d10ffbb

Browse files
author
Mykola Mokhnach
committed
Extend FluentWait, so one can set custom polling strategy
1 parent 48f3a96 commit d10ffbb

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client;
18+
19+
import com.google.common.base.Throwables;
20+
import org.openqa.selenium.TimeoutException;
21+
import org.openqa.selenium.WebDriverException;
22+
import org.openqa.selenium.support.ui.Clock;
23+
import org.openqa.selenium.support.ui.Duration;
24+
import org.openqa.selenium.support.ui.FluentWait;
25+
import org.openqa.selenium.support.ui.Sleeper;
26+
27+
import java.lang.reflect.Field;
28+
import java.util.List;
29+
import java.util.concurrent.TimeUnit;
30+
import java.util.function.Function;
31+
import java.util.function.Supplier;
32+
33+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
34+
import static java.util.concurrent.TimeUnit.SECONDS;
35+
36+
public class AppiumFluentWait<T> extends FluentWait<T> {
37+
private Function<IterationInfo, Duration> pollingStrategy = null;
38+
39+
public static class IterationInfo {
40+
private final long number;
41+
private final Duration elapsed;
42+
private final Duration total;
43+
private final Duration interval;
44+
45+
/**
46+
* The class is used to represent information about a single loop iteration in {@link #until(Function)}
47+
* method.
48+
*
49+
* @param number loop iteration number, starts from 1
50+
* @param elapsed the amount of elapsed time since the loop started
51+
* @param total the amount of total time to run the loop
52+
* @param interval the default time interval for each loop iteration
53+
*/
54+
public IterationInfo(long number, Duration elapsed, Duration total, Duration interval) {
55+
this.number = number;
56+
this.elapsed = elapsed;
57+
this.total = total;
58+
this.interval = interval;
59+
}
60+
61+
/**
62+
* The current iteration number.
63+
*
64+
* @return current iteration number. It starts from 1
65+
*/
66+
public long getNumber() {
67+
return number;
68+
}
69+
70+
/**
71+
* The amount of elapsed time.
72+
*
73+
* @return the amount of elapsed time
74+
*/
75+
public Duration getElapsed() {
76+
return elapsed;
77+
}
78+
79+
/**
80+
* The amount of total time.
81+
*
82+
* @return the amount of total time
83+
*/
84+
public Duration getTotal() {
85+
return total;
86+
}
87+
88+
/**
89+
* The current interval.
90+
*
91+
* @return The actual value of current interval or the default one if it is not set
92+
*/
93+
public Duration getInterval() {
94+
return interval;
95+
}
96+
}
97+
98+
/**
99+
* @param input The input value to pass to the evaluated conditions.
100+
*/
101+
public AppiumFluentWait(T input) {
102+
super(input);
103+
}
104+
105+
/**
106+
* @param input The input value to pass to the evaluated conditions.
107+
* @param clock The clock to use when measuring the timeout.
108+
* @param sleeper Used to put the thread to sleep between evaluation loops.
109+
*/
110+
public AppiumFluentWait(T input, Clock clock, Sleeper sleeper) {
111+
super(input, clock, sleeper);
112+
}
113+
114+
private <B> B getPrivateFieldValue(String fieldName, Class<B> fieldType) {
115+
try {
116+
final Field f = getClass().getSuperclass().getDeclaredField(fieldName);
117+
f.setAccessible(true);
118+
return fieldType.cast(f.get(this));
119+
} catch (NoSuchFieldException | IllegalAccessException e) {
120+
throw new WebDriverException(e);
121+
}
122+
}
123+
124+
private Object getPrivateFieldValue(String fieldName) {
125+
try {
126+
final Field f = getClass().getSuperclass().getDeclaredField(fieldName);
127+
f.setAccessible(true);
128+
return f.get(this);
129+
} catch (NoSuchFieldException | IllegalAccessException e) {
130+
throw new WebDriverException(e);
131+
}
132+
}
133+
134+
protected Clock getClock() {
135+
return getPrivateFieldValue("clock", Clock.class);
136+
}
137+
138+
protected Duration getTimeout() {
139+
return getPrivateFieldValue("timeout", Duration.class);
140+
}
141+
142+
protected Duration getInterval() {
143+
return getPrivateFieldValue("interval", Duration.class);
144+
}
145+
146+
protected Sleeper getSleeper() {
147+
return getPrivateFieldValue("sleeper", Sleeper.class);
148+
}
149+
150+
@SuppressWarnings("unchecked")
151+
protected List<Class<? extends Throwable>> getIgnoredExceptions() {
152+
return getPrivateFieldValue("ignoredExceptions", List.class);
153+
}
154+
155+
@SuppressWarnings("unchecked")
156+
protected Supplier<String> getMessageSupplier() {
157+
return getPrivateFieldValue("messageSupplier", Supplier.class);
158+
}
159+
160+
@SuppressWarnings("unchecked")
161+
protected T getInput() {
162+
return (T) getPrivateFieldValue("input");
163+
}
164+
165+
/**
166+
* Sets the strategy for polling. The default strategy is null,
167+
* which means, that polling interval is always a constant value and is
168+
* set by {@link #pollingEvery(long, TimeUnit)} method. Otherwise the value set by that
169+
* method might be just a helper to calculate the actual interval.
170+
* Although, by setting an alternative polling strategy you may flexibly control
171+
* the duration of this interval for each polling round.
172+
* For example we'd like to wait two times longer than before each time we cannot find
173+
* an element:
174+
* <code>
175+
* final Wait&lt;WebElement&gt; wait = new AppiumFluentWait&lt;&gt;(el)
176+
* .withPollingStrategy(info -&gt; new Duration(info.getNumber() * 2, TimeUnit.SECONDS))
177+
* .withTimeout(6, TimeUnit.SECONDS);
178+
* wait.until(WebElement::isDisplayed);
179+
* </code>
180+
* Or we want the next time period is Euler's number e raised to the power of current iteration
181+
* number:
182+
* <code>
183+
* final Wait&lt;WebElement&gt; wait = new AppiumFluentWait&lt;&gt;(el)
184+
* .withPollingStrategy(info -&gt; new Duration((long) Math.exp(info.getNumber()), TimeUnit.SECONDS))
185+
* .withTimeout(6, TimeUnit.SECONDS);
186+
* wait.until(WebElement::isDisplayed);
187+
* </code>
188+
* Or we'd like to have some advanced algorithm, which waits longer first, but then use the default interval when it
189+
* reaches some constant:
190+
* <code>
191+
* final Wait&lt;WebElement&gt; wait = new AppiumFluentWait&lt;&gt;(el)
192+
* .withPollingStrategy(info -&gt; new Duration(info.getNumber() &lt; 5
193+
* ? 4 - info.getNumber() : info.getInterval().in(TimeUnit.SECONDS), TimeUnit.SECONDS))
194+
* .withTimeout(30, TimeUnit.SECONDS)
195+
* .pollingEvery(1, TimeUnit.SECONDS);
196+
* wait.until(WebElement::isDisplayed);
197+
* </code>
198+
*
199+
* @param pollingStrategy Function instance, where the first parameter
200+
* is the information about the current loop iteration (see {@link IterationInfo})
201+
* and the expected result is the calculated interval
202+
* @return A self reference.
203+
*/
204+
public AppiumFluentWait<T> withPollingStrategy(Function<IterationInfo, Duration> pollingStrategy) {
205+
this.pollingStrategy = pollingStrategy;
206+
return this;
207+
}
208+
209+
/**
210+
* Repeatedly applies this instance's input value to the given function until one of the following
211+
* occurs:
212+
* <ol>
213+
* <li>the function returns neither null nor false,</li>
214+
* <li>the function throws an unignored exception,</li>
215+
* <li>the timeout expires,
216+
* <li>
217+
* <li>the current thread is interrupted</li>
218+
* </ol>
219+
*
220+
* @param isTrue the parameter to pass to the expected condition
221+
* @param <V> The function's expected return type.
222+
* @return The functions' return value if the function returned something different
223+
* from null or false before the timeout expired.
224+
* @throws TimeoutException If the timeout expires.
225+
*/
226+
@Override
227+
public <V> V until(Function<? super T, V> isTrue) {
228+
final long start = getClock().now();
229+
final long end = getClock().laterBy(getTimeout().in(MILLISECONDS));
230+
long iterationNumber = 1;
231+
Throwable lastException;
232+
while (true) {
233+
try {
234+
V value = isTrue.apply(getInput());
235+
if (value != null && (Boolean.class != value.getClass() || Boolean.TRUE.equals(value))) {
236+
return value;
237+
}
238+
239+
// Clear the last exception; if another retry or timeout exception would
240+
// be caused by a false or null value, the last exception is not the
241+
// cause of the timeout.
242+
lastException = null;
243+
} catch (Throwable e) {
244+
lastException = propagateIfNotIgnored(e);
245+
}
246+
247+
// Check the timeout after evaluating the function to ensure conditions
248+
// with a zero timeout can succeed.
249+
if (!getClock().isNowBefore(end)) {
250+
String message = getMessageSupplier() != null ? getMessageSupplier().get() : null;
251+
252+
String timeoutMessage = String.format(
253+
"Expected condition failed: %s (tried for %d second(s) with %s interval)",
254+
message == null ? "waiting for " + isTrue : message,
255+
getTimeout().in(SECONDS), getInterval());
256+
throw timeoutException(timeoutMessage, lastException);
257+
}
258+
259+
try {
260+
Duration interval = getInterval();
261+
if (pollingStrategy != null) {
262+
final IterationInfo info = new IterationInfo(iterationNumber,
263+
new Duration(getClock().now() - start, TimeUnit.MILLISECONDS), getTimeout(),
264+
interval);
265+
interval = pollingStrategy.apply(info);
266+
}
267+
getSleeper().sleep(interval);
268+
} catch (InterruptedException e) {
269+
Thread.currentThread().interrupt();
270+
throw new WebDriverException(e);
271+
}
272+
++iterationNumber;
273+
}
274+
}
275+
276+
protected Throwable propagateIfNotIgnored(Throwable e) {
277+
for (Class<? extends Throwable> ignoredException : getIgnoredExceptions()) {
278+
if (ignoredException.isInstance(e)) {
279+
return e;
280+
}
281+
}
282+
Throwables.throwIfUnchecked(e);
283+
throw new RuntimeException(e);
284+
}
285+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client.appium;
18+
19+
import io.appium.java_client.AppiumFluentWait;
20+
21+
import org.junit.Assert;
22+
import org.junit.Test;
23+
import org.openqa.selenium.TimeoutException;
24+
import org.openqa.selenium.support.ui.Duration;
25+
import org.openqa.selenium.support.ui.SystemClock;
26+
import org.openqa.selenium.support.ui.Wait;
27+
28+
import java.util.concurrent.TimeUnit;
29+
import java.util.concurrent.atomic.AtomicInteger;
30+
import java.util.function.Function;
31+
32+
import static org.hamcrest.MatcherAssert.assertThat;
33+
import static org.hamcrest.core.Is.is;
34+
import static org.hamcrest.core.IsEqual.equalTo;
35+
36+
public class AppiumFluentWaitTest {
37+
private static class FakeElement {
38+
public boolean isDisplayed() {
39+
return false;
40+
}
41+
}
42+
43+
@Test
44+
public void testDefaultStrategy() {
45+
final FakeElement el = new FakeElement();
46+
final Wait<FakeElement> wait = new AppiumFluentWait<>(el, new SystemClock(), duration -> {
47+
assertThat(duration.in(TimeUnit.SECONDS), is(equalTo(1)));
48+
Thread.sleep(duration.in(TimeUnit.MILLISECONDS));
49+
}).withPollingStrategy(AppiumFluentWait.IterationInfo::getInterval)
50+
.withTimeout(3, TimeUnit.SECONDS)
51+
.pollingEvery(1, TimeUnit.SECONDS);
52+
try {
53+
wait.until(FakeElement::isDisplayed);
54+
Assert.fail("TimeoutException is expected");
55+
} catch (TimeoutException e) {
56+
// this is expected
57+
}
58+
}
59+
60+
@Test
61+
public void testCustomStrategyOverridesDefaultInterval() {
62+
final FakeElement el = new FakeElement();
63+
final AtomicInteger callsCounter = new AtomicInteger(0);
64+
final Wait<FakeElement> wait = new AppiumFluentWait<>(el, new SystemClock(), duration -> {
65+
callsCounter.incrementAndGet();
66+
assertThat(duration.in(TimeUnit.SECONDS), is(equalTo(2)));
67+
Thread.sleep(duration.in(TimeUnit.MILLISECONDS));
68+
}).withPollingStrategy(info -> new Duration(2, TimeUnit.SECONDS))
69+
.withTimeout(3, TimeUnit.SECONDS)
70+
.pollingEvery(1, TimeUnit.SECONDS);
71+
try {
72+
wait.until(FakeElement::isDisplayed);
73+
Assert.fail("TimeoutException is expected");
74+
} catch (TimeoutException e) {
75+
// this is expected
76+
}
77+
assertThat(callsCounter.get(), is(equalTo(2)));
78+
}
79+
80+
@Test
81+
public void testIntervalCalculationForCustomStrategy() {
82+
final FakeElement el = new FakeElement();
83+
final AtomicInteger callsCounter = new AtomicInteger(0);
84+
// Linear dependency
85+
final Function<Long, Long> pollingStrategy = x -> x * 2;
86+
final Wait<FakeElement> wait = new AppiumFluentWait<>(el, new SystemClock(), duration -> {
87+
int callNumber = callsCounter.incrementAndGet();
88+
assertThat(duration.in(TimeUnit.SECONDS), is(equalTo(pollingStrategy.apply((long) callNumber))));
89+
Thread.sleep(duration.in(TimeUnit.MILLISECONDS));
90+
}).withPollingStrategy(info -> new Duration(pollingStrategy.apply(info.getNumber()), TimeUnit.SECONDS))
91+
.withTimeout(4, TimeUnit.SECONDS)
92+
.pollingEvery(1, TimeUnit.SECONDS);
93+
try {
94+
wait.until(FakeElement::isDisplayed);
95+
Assert.fail("TimeoutException is expected");
96+
} catch (TimeoutException e) {
97+
// this is expected
98+
}
99+
assertThat(callsCounter.get(), is(equalTo(2)));
100+
}
101+
}

0 commit comments

Comments
 (0)