package com.github.andreptb.fitnesse.selenium; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.text.MessageFormat; import java.time.Duration; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang.ObjectUtils; import org.apache.commons.lang.exception.ExceptionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.mutable.MutableObject; import org.openqa.selenium.By; import org.openqa.selenium.Capabilities; import org.openqa.selenium.InvalidElementStateException; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.OutputType; import org.openqa.selenium.TakesScreenshot; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.UnhandledAlertException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.remote.RemoteWebDriver; import org.openqa.selenium.remote.ScreenshotException; import org.openqa.selenium.support.ui.ExpectedCondition; import org.openqa.selenium.support.ui.UnexpectedTagNameException; import org.openqa.selenium.support.ui.WebDriverWait; import org.reflections.Reflections; import com.github.andreptb.fitnesse.selenium.SeleniumLocatorParser.WebElementSelector; import com.github.andreptb.fitnesse.util.FitnesseMarkup; /** * Utility class that wraps {@link WebDriver} instances. Each {@link #connect(String, String, String)} call * will associate a working instance of {@link WebDriver} and will be used until {@link #quit()} is used or another {@link #connect(String, String, String)} */ public class WebDriverHelper { /** * HTTP scheme prefix, to detect remote DRIVER */ private static final String HTTP_PREFIX = "http"; private static final String UNDEFINED_VALUE = "<<undefined_value>>"; private Logger logger = Logger.getLogger(WebDriverHelper.class.getName()); private SeleniumLocatorParser parser = new SeleniumLocatorParser(); private FitnesseMarkup fitnesseMarkup = new FitnesseMarkup(); private WebDriverCapabilitiesHelper capabilitiesHelper = new WebDriverCapabilitiesHelper(); private Map<Integer, WebDriver> driverCache = new LinkedHashMap<>(); private Integer currentDriverId; /** * @see #setTimeoutInSeconds(int) */ private int timeoutInSeconds = 20; /** * @see #getLastActionDurationInSeconds() */ private long lastActionDurationInSeconds; /** * @see #setStopTestOnFirstFailure(boolean) */ private boolean stopTestOnFirstFailure; /** * @see #setTakeScreenshotOnFailure(boolean) */ private boolean takeScreenshotOnFailure = true; private String dryRunWindow; /** * Creates a {@link WebDriver} instance with desired browser and capabilities. Capabilities should follow a key/value format * * @see WebDriverCapabilitiesHelper#parse(String, String, String) * @param browser to be initialized. Can be a remote driver URL * @param capabilities string. Should follow a key/value format * @param preferences string. Should follow a key/value format * @throws ReflectiveOperationException if remote driver class cannot be instantiated * @throws IOException if IO error occurs if invalid URL is used when connecting to remote drivers */ public void connect(String browser, String capabilities, String preferences) throws ReflectiveOperationException, IOException { int driverId = new HashCodeBuilder().append(browser).append(capabilities).append(preferences).toHashCode(); WebDriver driver = this.driverCache.get(driverId); if (isBrowserAvailable(driver)) { return; } quit(driverId); this.driverCache.put(driverId, createDriverConnection(browser, capabilities, preferences)); this.currentDriverId = driverId; } private WebDriver createDriverConnection(String browser, String capabilities, String preferences) throws MalformedURLException, ReflectiveOperationException { WebDriver driver = null; String cleanedBrowser = StringUtils.deleteWhitespace(this.parser.parse(browser).getOriginalSelector()); Capabilities parsedCapabilities = this.capabilitiesHelper.parse(cleanedBrowser, this.fitnesseMarkup.clean(capabilities), this.fitnesseMarkup.clean(preferences)); if (StringUtils.startsWithIgnoreCase(cleanedBrowser, WebDriverHelper.HTTP_PREFIX)) { driver = new RemoteWebDriver(new URL(cleanedBrowser), parsedCapabilities); } else { Reflections reflections = new Reflections(WebDriver.class.getPackage().getName()); for (Class<? extends WebDriver> availableDriver : reflections.getSubTypesOf(WebDriver.class)) { if (StringUtils.startsWithIgnoreCase(availableDriver.getSimpleName(), cleanedBrowser)) { driver = availableDriver.getConstructor(Capabilities.class).newInstance(parsedCapabilities); break; } } } if (driver == null) { throw new StopTestWithWebDriverException(MessageFormat.format("No suitable implementation found for [{0}] with capabilites: [{1}]", browser, capabilities)); } return driver; } /** * Quietly quits the current browser instance */ public void quit() { if (quit(this.currentDriverId) && MapUtils.isNotEmpty(this.driverCache)) { this.currentDriverId = this.driverCache.keySet().stream().findFirst().get(); } } private boolean quit(Integer driverId) { try { this.driverCache.remove(driverId).quit(); return true; } catch (Exception e) { // quits quietly } return false; } public boolean doWhenAvailable(String from, BiConsumer<WebDriver, WebElementSelector> callback) { getWhenAvailable(from, (driver, selector) -> { callback.accept(driver, selector); return StringUtils.stripToNull(selector.getExpectedValue()); }); return true; } private String respondForDryRun(WebDriver driver, WebElementSelector locator) { String currentWindow = driver.getWindowHandle(); By selector = locator.getBy(); if (selector != null) { if (!StringUtils.equals(currentWindow, this.dryRunWindow)) { driver.switchTo().window(this.dryRunWindow); } try { driver.findElement(locator.getBy()); } catch (NoSuchElementException e) { // element not found means that selenium is running properly } } String expectedValue = locator.getExpectedValue(); if(StringUtils.startsWith(expectedValue, FitnesseMarkup.SELECTOR_VALUE_DENY_INDICATOR)) { return WebDriverHelper.UNDEFINED_VALUE; } return expectedValue; } /** * Core function designed to provide callbacks with selenium context necessary to evaluate commands. Applies * the following rules: * <ul> * <li>Builds the context and passes the control to the callback (see {@link SeleniumLocatorParser#parse(String)})</li> * <li>If the callback is unable to find a {@link WebElement} to run commmands, or fails for any other reason, the callback will be reinvoked until a positive return happens or * {@link #getTimeoutInSeconds()} is reached</li> * <li>If the callback returns positively and the result don't match with {@link WebElementSelector#getExpectedValue()}, the callback will be reinvoked until the value matches or * {@link #getTimeoutInSeconds()} is reached</li> * <li>If the callback returns positively and the result match with {@link WebElementSelector#getExpectedValue()} (or {@link WebElementSelector#getExpectedValue()} is empty), the result will be * returned</li> * </ul> * * @param from selenium selector received by the fixture@param from * @param callback The callback to be invoked with {@link WebElementSelector} and {@link WebDriver} * @return the value returned from the callback * @throws StopTestWithWebDriverException if {@link #isBrowserAvailable()} returns false or if {@link #getStopTestOnFirstFailure()} is true and any failure occurs */ public String getWhenAvailable(String from, BiFunction<WebDriver, WebElementSelector, String> callback) { this.lastActionDurationInSeconds = NumberUtils.LONG_ZERO; WebElementSelector locator = this.parser.parse(this.fitnesseMarkup.clean(from)); WebDriver driver = this.driverCache.get(this.currentDriverId); if (!isBrowserAvailable()) { throw new StopTestWithWebDriverException("No browser instance available, please check if 'start browser' command completed successfuly"); } MutableObject<String> result = new MutableObject<>(); try { if (StringUtils.isNotBlank(this.dryRunWindow)) { return respondForDryRun(driver, locator); } Instant startInstant = Instant.now(); WebDriverWait wait = new WebDriverWait(driver, this.timeoutInSeconds); wait.ignoring(InvalidElementStateException.class); wait.ignoring(UnhandledAlertException.class); wait.ignoring(UnexpectedTagNameException.class); try { wait.until((ExpectedCondition<String>) waitingDriver -> { evaluate(waitingDriver, locator, callback, false, result); return result.getValue(); }); } catch (TimeoutException e) { if (this.stopTestOnFirstFailure) { throw e; } evaluate(driver, locator, callback, true, result); } finally { this.lastActionDurationInSeconds = Duration.between(startInstant, Instant.now()).getSeconds(); } } catch (RuntimeException e) { throw handleSeleniumException(e, driver); } return result.getValue(); } private RuntimeException handleSeleniumException(RuntimeException originalException, WebDriver driver) { String screenshotData = retrieveScreenshotPathFromException(originalException, driver); Throwable cause = Optional.ofNullable(ExceptionUtils.getRootCause(originalException)).orElse(originalException); String exceptionMessage = this.fitnesseMarkup.exceptionMessage(StringUtils.substringBefore(cause.getMessage(), StringUtils.LF), screenshotData); this.logger.log(Level.INFO, exceptionMessage, cause); try { Throwable convertedException = this.stopTestOnFirstFailure ? new StopTestWithWebDriverException(exceptionMessage, cause) : cause.getClass().getConstructor(String.class).newInstance(exceptionMessage); convertedException.setStackTrace(cause.getStackTrace()); return (RuntimeException) convertedException; } catch (Exception e) { this.logger.log(Level.FINE, "Failed to handle selenium failure response", e); } return originalException; } private String retrieveScreenshotPathFromException(Throwable originalException, WebDriver driver) { if (!this.takeScreenshotOnFailure) { return StringUtils.EMPTY; } try { if (originalException instanceof ScreenshotException) { return ((ScreenshotException) originalException).getBase64EncodedScreenshot(); } else if (driver instanceof TakesScreenshot) { return ((TakesScreenshot) driver).getScreenshotAs(OutputType.BASE64); } } catch (Exception se) { this.logger.log(Level.FINE, "Failed to retrieve screenshot after failure", se); } return StringUtils.EMPTY; } private void evaluate(WebDriver driver, WebElementSelector locator, BiFunction<WebDriver, WebElementSelector, String> callback, boolean disableValueCheck, MutableObject<String> resultHolder) { String result = StringUtils.stripToEmpty(callback.apply(driver, locator)); resultHolder.setValue(result); String expectedValue = locator.getExpectedValue(); if (disableValueCheck || StringUtils.isBlank(expectedValue) || this.fitnesseMarkup.compare(expectedValue, result)) { return; } throw new NoSuchElementException(MessageFormat.format("Element with unexpected value [Expected: {0}, Obtained: {1}]", expectedValue, result)); } /** * @return if browser is available and can be used */ public boolean isBrowserAvailable() { return isBrowserAvailable(this.driverCache.get(this.currentDriverId)); } private boolean isBrowserAvailable(WebDriver driver) { // http://stackoverflow.com/questions/27616470/webdriver-how-to-check-if-browser-still-exists-or-still-open String driverString = ObjectUtils.toString(driver); return StringUtils.isNotBlank(driverString) && !StringUtils.containsIgnoreCase(driverString, "null"); } /** * @param timeoutInSeconds Timeout to wait for elements to be present. Default is 20 seconds */ public void setTimeoutInSeconds(int timeoutInSeconds) { this.timeoutInSeconds = timeoutInSeconds; } /** * @return Timeout to wait for elements to be present. Default is 20 seconds */ public int getTimeoutInSeconds() { return this.timeoutInSeconds; } /** * @param stopTestOnFirstFailure If true, if any error occurs while running selenium actions, test will be stopped. */ public void setStopTestOnFirstFailure(boolean stopTestOnFirstFailure) { this.stopTestOnFirstFailure = stopTestOnFirstFailure; } public boolean getStopTestOnFirstFailure() { return this.stopTestOnFirstFailure; } /** * @return Seconds the last action took to complete */ public long getLastActionDurationInSeconds() { return this.lastActionDurationInSeconds; } public boolean getTakeScreenshotOnFailure() { return this.takeScreenshotOnFailure; } /** * @param takeScreenshotOnFailure If true, will embed exceptions with screenshot data (if available). Default is <code>true</code> */ public void setTakeScreenshotOnFailure(boolean takeScreenshotOnFailure) { this.takeScreenshotOnFailure = takeScreenshotOnFailure; } public String getDryRunWindow() { return this.dryRunWindow; } public void setDryRunWindow(String dryRunWindow) { this.dryRunWindow = dryRunWindow; } /** * {@link Exception} class so test can be stopped. See <a href="http://www.fitnesse.org/FitNesse.FullReferenceGuide.UserGuide.WritingAcceptanceTests.SliM.SlimProtocol">FitNesse reference guide * (Aborting a test) section</a>. */ public static class StopTestWithWebDriverException extends RuntimeException { public StopTestWithWebDriverException(String message, Throwable cause) { super(message); } public StopTestWithWebDriverException(String message) { super(message); } } }