View Javadoc
1   /*
2    * Copyright 2023 Adrian Herscu
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
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  package dev.aherscu.qa.jgiven.webdriver.steps;
17  
18  import static dev.aherscu.qa.jgiven.commons.utils.WebDriverEx.*;
19  import static java.lang.Boolean.*;
20  import static java.util.Objects.*;
21  
22  import java.net.*;
23  import java.time.*;
24  import java.util.*;
25  import java.util.function.*;
26  
27  import javax.annotation.concurrent.*;
28  
29  import org.openqa.selenium.*;
30  import org.openqa.selenium.NoSuchContextException;
31  import org.openqa.selenium.NoSuchElementException;
32  
33  import com.tngtech.jgiven.annotation.*;
34  
35  import dev.aherscu.qa.jgiven.commons.steps.*;
36  import dev.aherscu.qa.jgiven.commons.utils.*;
37  import dev.aherscu.qa.jgiven.webdriver.model.*;
38  import io.appium.java_client.*;
39  import io.appium.java_client.remote.*;
40  import io.appium.java_client.windows.*;
41  import lombok.*;
42  import lombok.extern.slf4j.*;
43  import net.jodah.failsafe.*;
44  
45  /**
46   * Generic browser actions.
47   *
48   * @param <SELF>
49   *            the type of the subclass
50   * @author aherscu
51   */
52  @ThreadSafe
53  @Slf4j
54  @SuppressWarnings({ "boxing", "ClassWithTooManyMethods" })
55  public class WebDriverActions<SELF extends WebDriverActions<SELF>>
56      extends GenericActions<WebDriverScenarioType, SELF>
57      implements MayAttachScreenshots<SELF> {
58      /**
59       * Expected browser object.
60       */
61      @ExpectedScenarioState
62      protected ThreadLocal<WebDriverEx> webDriver;
63  
64      @Override
65      @Hidden
66      public SELF attaching_screenshot() {
67          return attaching_screenshot(0);
68      }
69  
70      @Override
71      @Hidden
72      public SELF attaching_screenshot(final int delayMs) {
73          attachScreenshot(thisWebDriver(), delayMs);
74          return self();
75      }
76  
77      /**
78       * Clicks a specified element, retrying as much as configured; the element
79       * is retrieved via specified locator before each retry.
80       *
81       * @param locator
82       *            the element
83       * @return {@link #self()}
84       */
85      public SELF clicking(final By locator) {
86          return clicking(() -> element(locator));
87      }
88  
89      /**
90       * Clicks a specified element, retrying as much as configured; the element
91       * is retrieved via specified supplier before each retry.
92       *
93       * <p>
94       * {@code click} metric will be updated and includes locating the element
95       * and scrolling into view.
96       * </p>
97       *
98       * @param elementSupplier
99       *            late-bound element
100      * @return {@link #self()}
101      */
102     public SELF clicking(final Supplier<WebElement> elementSupplier) {
103         try (val clickTimerContext = clickTimer.time()) {
104             return retrying(self -> clicking_once(elementSupplier));
105         }
106     }
107 
108     public SELF clicking_once(final By locator) {
109         return clicking_once(() -> element(locator));
110     }
111 
112     public SELF clicking_once(final Supplier<WebElement> elementSupplier) {
113         val element = elementSupplier.get();
114         log.debug("clicking {}", descriptionOf(element));
115         element.click();
116         return self();
117     }
118 
119     /**
120      * Finds all elements matching specified locator.
121      *
122      * <p>
123      * {@code locateTimer} metric will be updated not including the scrolling
124      * into view.
125      * </p>
126      *
127      * @param locator
128      *            the locator
129      * @return the element
130      */
131     @Hidden
132     public List<WebElement> elements(final By locator) {
133         log.debug("locating {}", locator);
134         return elements(locator, thisWebDriver().asGeneric());
135     }
136 
137     /**
138      * Clicks a specified element even if hidden or out of view, retrying as
139      * much as configured; the element is retrieved via specified supplier
140      * before each retry.
141      *
142      * <p>
143      * {@code click} metric will be updated and includes locating the element.
144      * </p>
145      *
146      * @param elementSupplier
147      *            late-bound element
148      * @return {@link #self()}
149      */
150     @As("clicking")
151     public SELF forcefullyClicking(final Supplier<WebElement> elementSupplier) {
152         try (val clickTimerContext = clickTimer.time()) {
153             return retrying(self -> {
154                 val element = elementSupplier.get();
155                 log.debug("forcefully clicking {}", descriptionOf(element));
156                 thisWebDriver().forceClick(element);
157                 return self;
158             });
159         }
160     }
161 
162     /**
163      * Clicks a specified element even if hidden or out of view, retrying as
164      * much as configured; the element is retrieved via specified locator before
165      * each retry.
166      *
167      * @param locator
168      *            the element
169      * @return {@link #self()}
170      */
171     @As("clicking")
172     public SELF forcefullyClicking(final By locator) {
173         return forcefullyClicking(() -> element(locator));
174     }
175 
176     /**
177      * Long presses a specified element, retrying as much as configured; the
178      * element is retrieved via specified supplier before each retry.
179      *
180      * @param elementSupplier
181      *            late-bound element
182      * @return {@link #self()}
183      */
184     public SELF long_pressing(final Supplier<WebElement> elementSupplier) {
185         return retrying(self -> {
186             val element = elementSupplier.get();
187             log.debug("long pressing {}", descriptionOf(element));
188             thisWebDriver()
189                 .dispatch(new WebDriverEx.PointerEvent(
190                     PointerEvent.PointerEventType.pointerdown,
191                     new HashMap<String, Object>() {
192                         {
193                             put("bubbles", TRUE);
194                         }
195                     }),
196                     element);
197             return self;
198         });
199     }
200 
201     /**
202      * Long presses a specified element, retrying as much as configured;the
203      * element is retrieved via specified locator before each retry.
204      *
205      * @param locator
206      *            the element
207      * @return {@link #self()}
208      */
209     public SELF long_pressing(final By locator) {
210         return long_pressing(() -> element(locator));
211     }
212 
213     /**
214      * Opens an URL into the given browser.
215      *
216      * @param url
217      *            the URL to open
218      * @return {@link #self()}
219      */
220     public SELF opening(final URI url) {
221         thisWebDriver().asGeneric().get(url.toString());
222 
223         if (!thisWebDriver().is(JavascriptExecutor.class))
224             return self();
225 
226         return retrying(
227             self -> {
228                 if (thisWebDriver().asJavaScriptExecutor()
229                     .executeScript("return document.readyState")
230                     .equals("complete"))
231                     return self;
232                 throw new FailsafeException();
233             });
234     }
235 
236     @SneakyThrows(URISyntaxException.class)
237     public SELF opening(final String url) {
238         return opening(new URI(url));
239     }
240 
241     /**
242      * Rotates devices to specified orientation.
243      *
244      * @param orientation
245      *            the orientation
246      *
247      * @return #self()
248      */
249     public SELF rotating_device_to(final ScreenOrientation orientation) {
250         ((SupportsRotation) thisWebDriver()).rotate(orientation);
251         return self();
252     }
253 
254     /**
255      * Sends the application to background for specified duration.
256      *
257      * @param duration
258      *            the duration
259      * @return {@link #self()}
260      */
261     public SELF sending_application_to_background_for(final Duration duration) {
262         log.debug("sending application to background for {}", duration);
263         ((InteractsWithApps) thisWebDriver()).runAppInBackground(duration);
264         log.debug("returned from background");
265         return self();
266     }
267 
268     /**
269      * Submits the form containing the specified field.
270      *
271      * @param locator
272      *            the field
273      * @return {@link #self()}
274      */
275     // TODO provide a late-bound alternative like in long_pressing
276     public SELF submitting_the_form_containing(final By locator) {
277         return retrying(self -> {
278             log.debug("submitting {}", locator);
279             element(locator).submit();
280             return self();
281         });
282     }
283 
284     /**
285      * Terminates the specified application.
286      *
287      * @param appId
288      *            the application identifier
289      * @return {@link #self()}
290      */
291     public SELF terminating_application(final String appId) {
292         log.debug("application {} was running and stopped successfully: {}",
293             appId,
294             ((InteractsWithApps) thisWebDriver()).terminateApp(appId));
295         return self();
296     }
297 
298     /**
299      * Types into a field, clearing it before and hiding the keyboard
300      * afterwards.
301      *
302      * @param value
303      *            the value to type
304      * @param elementSupplier
305      *            late-bound element
306      * @return {@link #self()}
307      */
308     public SELF typing_$_into(
309         @Quoted final String value,
310         final Supplier<WebElement> elementSupplier) {
311         try (val sendKeysTimerContext = sendKeysTimer.time()) {
312             return retrying(self -> {
313                 val element = elementSupplier.get();
314                 log.debug("typing {} into {}", value, descriptionOf(element));
315                 // NOTE: this method does not work on ios
316                 // element.sendKeys(Keys.CONTROL + "a");
317                 // element.sendKeys(Keys.DELETE);
318                 element.clear();
319                 element.sendKeys(value);
320                 return self.hiding_keyboard();
321             });
322         }
323     }
324 
325     /**
326      * Types into a field, clearing it before and hiding the keyboard
327      * afterwards.
328      *
329      * @param value
330      *            the value to type
331      * @param locator
332      *            the field
333      * @return {@link #self()}
334      */
335     public SELF typing_$_into(
336         @Quoted final String value,
337         final By locator) {
338         return typing_$_into(value, () -> element(locator));
339     }
340 
341     public SELF typing_$_into_without_clearing(
342         @Quoted final String value,
343         final Supplier<WebElement> elementSupplier) {
344         try (val sendKeysTimerContext = sendKeysTimer.time()) {
345             return retrying(self -> {
346                 val element = elementSupplier.get();
347                 log.debug("typing {} into {}", value, descriptionOf(element));
348                 element.sendKeys(value);
349                 return self.hiding_keyboard();
350             });
351         }
352     }
353 
354     public SELF typing_$_into_without_clearing(
355         @Quoted final String value,
356         final By locator) {
357         return typing_$_into_without_clearing(value, () -> element(locator));
358     }
359 
360     /**
361      * Activates specified application.
362      *
363      * @param appId
364      *            the application identifier
365      * @return {@link #self()}
366      */
367     @Hidden
368     protected SELF activating_application(final String appId) {
369         log.debug("activating application {}", appId);
370         ((InteractsWithApps) thisWebDriver()).activateApp(appId);
371         return self();
372     }
373 
374     /**
375      * Finds an element by specified locator and brings it into view. If the
376      * locator is matching multiple elements then the first one is returned.
377      *
378      * <p>
379      * {@code locateTimer} metric will be updated not including the scrolling
380      * into view.
381      * </p>
382      *
383      * @param locator
384      *            the locator
385      * @return the element
386      * @throws NoSuchElementException
387      *             If no matching elements are found
388      */
389     @Hidden
390     protected WebElement element(final By locator) {
391         log.debug("locating {}", locator);
392         return element(locator, thisWebDriver().asGeneric());
393     }
394 
395     /**
396      * Retrieves matching DOM elements by specified locator and context. Retries
397      * several time if the count of elements is zero.
398      *
399      * <p>
400      * {@code locateTimer} metric will be updated.
401      * </p>
402      *
403      * @param locator
404      *            the locator
405      * @return list of DOM elements
406      */
407     @Hidden
408     protected List<WebElement> ensureElements(final By locator) {
409         log.debug("locating {}", locator);
410         return ensureElements(locator, thisWebDriver().asGeneric());
411     }
412 
413     /**
414      * Retrieves matching DOM elements by specified locator and context. Retries
415      * several time if the count of elements is zero.
416      *
417      * <p>
418      * {@code locateTimer} metric will be updated.
419      * </p>
420      *
421      * @param locator
422      *            the locator
423      * @return true if there are matching DOM elements
424      */
425     @Hidden
426     protected boolean hasElements(final By locator) {
427         return !ensureElements(locator).isEmpty();
428     }
429 
430     /**
431      * Hides the keyboard by clicking on the screen.
432      *
433      * @return {@link #self()}
434      */
435     @Hidden
436     protected SELF hiding_keyboard() {
437         log.debug("hiding the keyboard");
438         if (webDriver.get().asGeneric() instanceof WindowsDriver)
439             // WORKAROUND: temporary solution for desktop apps
440             return self();
441 
442         // WORKAROUND: not supported on all devices or operating systems
443         // webDriver.get().asMobile().hideKeyboard();
444         return forcefullyClicking(By.xpath("/html/body"));
445         // return self();
446     }
447 
448     /**
449      * Scrolls specified element into view.
450      *
451      * <p>
452      * {@code scrollIntoView} metric will be updated.
453      * </p>
454      *
455      * @param element
456      *            the element to scroll into view
457      * @return the element
458      */
459     @Hidden
460     @Override
461     protected WebElement scrollIntoView(final WebElement element) {
462         try (val scrollIntoViewTimerContext = scrollIntoViewTimer.time()) {
463             log.debug("scrolling to {}", descriptionOf(element));
464             thisWebDriver().scrollIntoView(element);
465         }
466         return element;
467     }
468 
469     /**
470      * Switches to specified Appium context.
471      *
472      * @param byRule
473      *            naming rule of Appium context
474      * @return {@link #self()}
475      * @throws NoSuchContextException
476      *             if no such context exists
477      */
478     @Hidden
479     protected SELF switching_to_context(final Predicate<String> byRule) {
480         return context(byRule, (ContextAware) thisWebDriver());
481     }
482 
483     /**
484      * Switches to specified window.
485      *
486      * @param nameOrHandle
487      *            the name of the window
488      * @return {@link #self()}
489      */
490     @Hidden
491     protected SELF switching_to_window(@Hidden final String nameOrHandle) {
492         log.debug("switching to window {}", nameOrHandle);
493         thisWebDriver().asGeneric().switchTo().window(nameOrHandle);
494         return self();
495     }
496 
497     protected final WebDriverEx thisWebDriver() {
498         return requireNonNull(
499             requireNonNull(webDriver, "web driver fixtures stage omitted")
500                 .get(),
501             "web driver not initialized");
502     }
503 }