package teammates.e2e.pageobjects; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import java.io.File; import java.io.IOException; import java.lang.reflect.Constructor; import java.util.List; import java.util.Map; import org.openqa.selenium.By; import org.openqa.selenium.Dimension; import org.openqa.selenium.InvalidElementStateException; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.Keys; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; import org.openqa.selenium.remote.RemoteWebElement; import org.openqa.selenium.remote.UselessFileDetector; import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.PageFactory; import org.openqa.selenium.support.ui.ExpectedCondition; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; import teammates.common.util.ThreadHelper; import teammates.common.util.Url; import teammates.common.util.retry.MaximumRetriesExceededException; import teammates.common.util.retry.RetryManager; import teammates.common.util.retry.RetryableTask; import teammates.e2e.util.TestProperties; import teammates.test.driver.FileHelper; /** * An abstract class that represents a browser-loaded page of the app and * provides ways to interact with it. Also contains methods to validate some * aspects of the page. .e.g, html page source. <br> * * <p>Note: We are using the PageObjects pattern here. * * @see <a href="https://code.google.com/p/selenium/wiki/PageObjects">https://code.google.com/p/selenium/wiki/PageObjects</a> */ public abstract class AppPage { private static final String CLEAR_ELEMENT_SCRIPT; private static final String SCROLL_ELEMENT_TO_CENTER_AND_CLICK_SCRIPT; private static final String ADD_CHANGE_EVENT_HOOK; static { try { ADD_CHANGE_EVENT_HOOK = FileHelper.readFile("src/e2e/resources/scripts/addChangeEventHook.js"); CLEAR_ELEMENT_SCRIPT = FileHelper.readFile("src/e2e/resources/scripts/clearElementWithoutEvents.js"); SCROLL_ELEMENT_TO_CENTER_AND_CLICK_SCRIPT = FileHelper .readFile("src/e2e/resources/scripts/scrollElementToCenterAndClick.js"); } catch (IOException e) { throw new RuntimeException(e); } } /** Browser instance the page is loaded into. */ protected Browser browser; /** Use for retrying due to transient UI issues. */ protected RetryManager uiRetryManager = new RetryManager((TestProperties.TEST_TIMEOUT + 1) / 2); /** Firefox change handler for handling when `change` events are not fired in Firefox. */ private final FirefoxChangeHandler firefoxChangeHandler; @FindBy(linkText = "Profile") private WebElement studentProfileTab; /** * Used by subclasses to create a {@code AppPage} object to wrap around the * given {@code browser} object. Fails if the page content does not match * the page type, as defined by the sub-class. */ public AppPage(Browser browser) { this.browser = browser; this.firefoxChangeHandler = new FirefoxChangeHandler(); //legit firefox boolean isCorrectPageType = containsExpectedPageContents(); if (isCorrectPageType) { return; } // To minimize test failures due to eventual consistency, we try to // reload the page and compare once more. System.out.println("#### Incorrect page type: going to try reloading the page."); ThreadHelper.waitFor(2000); reloadPage(); isCorrectPageType = containsExpectedPageContents(); if (isCorrectPageType) { return; } System.out.println("######### Not in the correct page! ##########"); throw new IllegalStateException("Not in the correct page!"); } /** * Fails if the new page content does not match content expected in a page of * the type indicated by the parameter {@code typeOfPage}. */ public static <T extends AppPage> T getNewPageInstance(Browser currentBrowser, Url url, Class<T> typeOfPage) { currentBrowser.driver.get(url.toAbsoluteString()); return getNewPageInstance(currentBrowser, typeOfPage); } /** * Fails if the new page content does not match content expected in a page of * the type indicated by the parameter {@code typeOfPage}. */ public static <T extends AppPage> T getNewPageInstance(Browser currentBrowser, Class<T> typeOfPage) { try { Constructor<T> constructor = typeOfPage.getConstructor(Browser.class); T page = constructor.newInstance(currentBrowser); PageFactory.initElements(currentBrowser.driver, page); return page; } catch (Exception e) { throw new RuntimeException(e); } } /** * Gives an AppPage instance based on the given Browser. */ public static AppPage getNewPageInstance(Browser currentBrowser) { return getNewPageInstance(currentBrowser, GenericAppPage.class); } /** * Fails if the new page content does not match content expected in a page of * the type indicated by the parameter {@code newPageType}. */ public <T extends AppPage> T changePageType(Class<T> newPageType) { return getNewPageInstance(browser, newPageType); } /** * Gives a {@link LoginPage} instance based on the given {@link Browser} and test configuration. * Fails if the page content does not match the content of the expected login page. */ public static LoginPage createCorrectLoginPageType(Browser browser) { Class<? extends LoginPage> cls = TestProperties.isDevServer() ? DevServerLoginPage.class : GoogleLoginPage.class; return getNewPageInstance(browser, cls); } public <E> E waitFor(ExpectedCondition<E> expectedCondition) { WebDriverWait wait = new WebDriverWait(browser.driver, TestProperties.TEST_TIMEOUT); return wait.until(expectedCondition); } /** * Waits until the page is fully loaded. */ public void waitForPageToLoad() { waitForPageToLoad(false); } /** * Waits until the page is fully loaded. * * @param excludeToast Set this to true if toast message's disappearance should not be counted * as criteria for page load's completion. */ public void waitForPageToLoad(boolean excludeToast) { browser.waitForPageLoad(excludeToast); } public void waitForElementVisibility(WebElement element) { waitFor(ExpectedConditions.visibilityOf(element)); } public void waitForElementVisibility(By by) { waitFor(ExpectedConditions.visibilityOfElementLocated(by)); } public void waitForElementToBeClickable(WebElement element) { waitFor(ExpectedConditions.elementToBeClickable(element)); } /** * Waits until an element is no longer attached to the DOM or the timeout expires. * @param element the WebElement * @throws TimeoutException if the timeout defined in * {@link TestProperties#TEST_TIMEOUT} expires * @see org.openqa.selenium.support.ui.FluentWait#until(java.util.function.Function) */ public void waitForElementStaleness(WebElement element) { waitFor(ExpectedConditions.stalenessOf(element)); } /** * Waits for a confirmation modal to appear and click the confirm button. */ public void waitForConfirmationModalAndClickOk() { waitForModalShown(); WebElement okayButton = browser.driver.findElement(By.className("modal-btn-ok")); waitForElementToBeClickable(okayButton); clickDismissModalButtonAndWaitForModalHidden(okayButton); } /** * Waits for a confirmation modal to appear and click the cancel button. */ public void waitForConfirmationModalAndClickCancel() { waitForModalShown(); WebElement cancelButton = browser.driver.findElement(By.className("modal-btn-cancel")); waitForElementToBeClickable(cancelButton); clickDismissModalButtonAndWaitForModalHidden(cancelButton); } private void waitForModalShown() { // Possible exploration: Change to listening to modal shown event as // this is based on the implementation detail assumption that once modal-backdrop is added the modal is shown waitForElementVisibility(By.className("modal-backdrop")); } void waitForModalHidden(WebElement modalBackdrop) { // Possible exploration: Change to listening to modal hidden event as // this is based on the implementation detail assumption that once modal-backdrop is removed the modal is hidden waitForElementStaleness(modalBackdrop); } /** * Waits for the element to appear in the page, up to the timeout specified. */ public WebElement waitForElementPresence(By by) { return waitFor(ExpectedConditions.presenceOfElementLocated(by)); } public void reloadPage() { browser.driver.get(browser.driver.getCurrentUrl()); waitForPageToLoad(); } protected Object executeScript(String script, Object... args) { JavascriptExecutor javascriptExecutor = (JavascriptExecutor) browser.driver; return javascriptExecutor.executeScript(script, args); } /** * Returns the HTML source of the currently loaded page. * TODO: remove this method as it does not return necessary html anymore since frontend is generated by angular */ public String getPageSource() { return browser.driver.getPageSource(); } public String getTitle() { return browser.driver.getTitle(); } public String getPageTitle() { By headerTag = By.tagName("h1"); waitForElementPresence(headerTag); return browser.driver.findElement(headerTag).getText(); } public void click(By by) { WebElement element = browser.driver.findElement(by); click(element); } protected void click(WebElement element) { executeScript("arguments[0].click();", element); } /** * Simulates the clearing and sending of keys to an element. * * <p><b>Note:</b> This method is not the same as using {@link WebElement#clear} followed by {@link WebElement#sendKeys}. * It avoids double firing of the {@code change} event which may occur when {@link WebElement#clear} is followed by * {@link WebElement#sendKeys}. * * @see AppPage#clearWithoutEvents(WebElement) */ private void clearAndSendKeys(WebElement element, CharSequence... keysToSend) { Map<String, Object> result = clearWithoutEvents(element); @SuppressWarnings("unchecked") Map<String, String> errors = (Map<String, String>) result.get("errors"); if (errors != null) { throw new InvalidElementStateException(errors.get("detail")); } element.sendKeys(keysToSend); } /** * Clears any kind of editable element, but without firing the {@code change} event (unlike {@link WebElement#clear()}). * Avoid using this method if {@link WebElement#clear()} meets the requirements as this method depends on implementation * details. */ private Map<String, Object> clearWithoutEvents(WebElement element) { // This method is a close mirror of HtmlUnitWebElement#clear(), except that events are not handled. Note that // HtmlUnitWebElement is mirrored as opposed to RemoteWebElement (which is used with actual browsers) for convenience // and the implementation can differ. checkNotNull(element); // Adapted from ExpectedConditions#stalenessOf which forces a staleness check. This allows a meaningful // StaleElementReferenceException to be thrown rather than just getting a boolean from ExpectedConditions. element.isEnabled(); // Fail safe in case the implementation of staleness checks is changed if (isExpectedCondition(ExpectedConditions.stalenessOf(element))) { throw new AssertionError( "Element is stale but should have been caught earlier by element.isEnabled()."); } @SuppressWarnings("unchecked") Map<String, Object> result = (Map<String, Object>) executeScript(CLEAR_ELEMENT_SCRIPT, element); return result; } protected void fillTextBox(WebElement textBoxElement, String value) { try { scrollElementToCenterAndClick(textBoxElement); } catch (WebDriverException e) { // It is important that a text box element is clickable before we fill it but due to legacy reasons we continue // attempting to fill the text box element even if it's not clickable (which may lead to an unexpected failure // later on) System.out.println("Unexpectedly not able to click on the text box element because of: "); System.out.println(e); } // If the intended value is empty `clear` works well enough for us if (value.isEmpty()) { textBoxElement.clear(); return; } // Otherwise we need to do special handling of entering input because `clear` and `sendKeys` work differently. // See documentation for `clearAndSendKeys` for more details. clearAndSendKeys(textBoxElement, value); // Add event hook before blurring the text box element so we can detect the event. firefoxChangeHandler.addChangeEventHook(textBoxElement); textBoxElement.sendKeys(Keys.TAB); // blur the element to receive events // Although events should not be manually fired, the `change` event does not fire when text input is changed if // Firefox is not in focus. Setting profile option `focusmanager.testmode = true` does not help as well. // A temporary solution is to fire the `change` event until the buggy behavior is fixed. // More details can be found in the umbrella issue of related bugs in the following link: // https://github.com/mozilla/geckodriver/issues/906 // The firing of `change` event is also imperfect because no check for the type of the elements is done before firing // the event, for instance a `change` event will be wrongly fired on any content editable element. The firing time of // `change` events is also incorrect for several `input` types such as `checkbox` and `date`. // See: https://developer.mozilla.org/en-US/docs/Web/Events/change firefoxChangeHandler.fireChangeEventIfNotFired(textBoxElement); } protected void fillFileBox(RemoteWebElement fileBoxElement, String fileName) { if (fileName.isEmpty()) { fileBoxElement.clear(); } else { fileBoxElement.setFileDetector(new UselessFileDetector()); String filePath = new File(fileName).getAbsolutePath(); fileBoxElement.sendKeys(filePath); } } /** * 'check' the check box, if it is not already 'checked'. * No action taken if it is already 'checked'. */ protected void markCheckBoxAsChecked(WebElement checkBox) { waitForElementVisibility(checkBox); if (!checkBox.isSelected()) { click(checkBox); } } /** * 'uncheck' the check box, if it is not already 'unchecked'. * No action taken if it is already 'unchecked'. */ protected void markCheckBoxAsUnchecked(WebElement checkBox) { waitForElementVisibility(checkBox); if (checkBox.isSelected()) { click(checkBox); } } /** * Returns the value of the cell located at {@code (row, column)} * from the first table (which is of type {@code class=table}) in the page. */ public String getCellValueFromDataTable(int row, int column) { return getCellValueFromDataTable(0, row, column); } /** * Returns the value of the cell located at {@code (row, column)} * from the nth(0-index-based) table (which is of type {@code class=table}) in the page. */ public String getCellValueFromDataTable(int tableNum, int row, int column) { WebElement tableElement = browser.driver.findElements(By.className("table")).get(tableNum); WebElement trElement = tableElement.findElements(By.tagName("tr")).get(row); WebElement tdElement = trElement.findElements(By.tagName("td")).get(column); return tdElement.getText(); } /** * Asserts that all values in the body of the given table are equal to the expectedTableBodyValues. */ protected void verifyTableBodyValues(WebElement table, String[][] expectedTableBodyValues) { List<WebElement> rows = table.findElement(By.tagName("tbody")).findElements(By.tagName("tr")); assertTrue(expectedTableBodyValues.length <= rows.size()); for (int rowIndex = 0; rowIndex < expectedTableBodyValues.length; rowIndex++) { verifyTableRowValues(rows.get(rowIndex), expectedTableBodyValues[rowIndex]); } } /** * Asserts that all values in the given table row are equal to the expectedRowValues. */ protected void verifyTableRowValues(WebElement row, String[] expectedRowValues) { List<WebElement> cells = row.findElements(By.tagName("td")); assertTrue(expectedRowValues.length <= cells.size()); for (int cellIndex = 0; cellIndex < expectedRowValues.length; cellIndex++) { assertEquals(expectedRowValues[cellIndex], cells.get(cellIndex).getText()); } } /** * Clicks the element and clicks 'Yes' in the follow up dialog box. * Fails if there is no dialog box. * @return the resulting page. */ public AppPage clickAndConfirm(WebElement elementToClick) { click(elementToClick); waitForConfirmationModalAndClickOk(); return this; } /** * Equivalent of clicking the 'Profile' tab on the top menu of the page. * @return the loaded page */ public StudentProfilePage loadProfileTab() { click(studentProfileTab); waitForPageToLoad(); return changePageType(StudentProfilePage.class); } /** * Returns True if the page contains some basic elements expected in a page of the * specific type. e.g., the top heading. */ protected abstract boolean containsExpectedPageContents(); /** * Returns True if there is a corresponding element for the given locator. */ public boolean isElementPresent(By by) { return browser.driver.findElements(by).size() != 0; } /** * Returns True if there is a corresponding element for the given id or name. */ public boolean isElementPresent(String elementId) { try { browser.driver.findElement(By.id(elementId)); return true; } catch (NoSuchElementException e) { return false; } } public boolean isElementVisible(String elementId) { try { return browser.driver.findElement(By.id(elementId)).isDisplayed(); } catch (NoSuchElementException e) { return false; } } public boolean isElementVisible(By by) { try { return browser.driver.findElement(by).isDisplayed(); } catch (NoSuchElementException e) { return false; } } /** * Returns true if the expected condition is evaluated to true immediately. * @see ExpectedConditions */ private boolean isExpectedCondition(ExpectedCondition<?> expectedCondition) { Object value = expectedCondition.apply(browser.driver); if (value == null) { return false; } if (value.getClass() == Boolean.class) { return (boolean) value; } else { return true; } } /** * Clicks a button (can be inside or outside the modal) that dismisses the modal and waits for the modal to be hidden. * The caller must ensure the button is in the modal or a timeout will occur while waiting for the modal to be hidden. * @param dismissModalButton a button that dismisses the modal */ public void clickDismissModalButtonAndWaitForModalHidden(WebElement dismissModalButton) { // Note: Should first check if the button can actually dismiss the modal otherwise the state will be consistent. // However, it is too difficult to check. WebElement modalBackdrop = browser.driver.findElement(By.className("modal-backdrop")); click(dismissModalButton); waitForModalHidden(modalBackdrop); } /** * Scrolls element to center and clicks on it. * * <p>As compared to {@link org.openqa.selenium.interactions.Actions#moveToElement(WebElement)}, this method is * more reliable as the element will not get blocked by elements such as the header. * * <p>Furthermore, {@link org.openqa.selenium.interactions.Actions#moveToElement(WebElement)} is currently not * working in Geckodriver. * * <p><b>Note:</b> A "scroll into view" Actions primitive is in progress and may allow scrolling element to center. * Tracking issue: * <a href="https://github.com/w3c/webdriver/issues/1005">Missing "scroll into view" Actions primitive</a>. * * <p>Also note that there are some other caveats, for example * {@code new Actions(browser.driver).moveToElement(...).click(...).perform()} does not behave consistently across * browsers. * <ul> * <li>In FirefoxDriver, the element is scrolled to and then a click is attempted on the element. * <li>In ChromeDriver, the mouse is scrolled to the element and then a click is attempted on the mouse coordinate, * which means another element can actually be clicked (such as the header or a blocking pop-up). * </ul> * * <p>ChromeDriver also automatically scrolls to an element when clicking an element if it is not in the viewport. */ void scrollElementToCenterAndClick(WebElement element) { // TODO: migrate to `scrollIntoView` when Geckodriver is adopted executeScript(SCROLL_ELEMENT_TO_CENTER_AND_CLICK_SCRIPT, element); element.click(); } /** * Asserts message in toast is equal to the expected message. */ public void verifyStatusMessage(String expectedMessage) { verifyStatusMessageWithLinks(expectedMessage, new String[] {}); } /** * Asserts message in toast is equal to the expected message and contains the expected links. */ public void verifyStatusMessageWithLinks(String expectedMessage, String[] expectedLinks) { WebElement[] statusMessage = new WebElement[1]; try { uiRetryManager.runUntilNoRecognizedException(new RetryableTask("Verify status to user") { @Override public void run() { statusMessage[0] = waitForElementPresence(By.className("toast-body")); assertEquals(expectedMessage, statusMessage[0].getText()); } }, WebDriverException.class, AssertionError.class); } catch (MaximumRetriesExceededException e) { statusMessage[0] = waitForElementPresence(By.className("toast-body")); assertEquals(expectedMessage, statusMessage[0].getText()); } finally { if (expectedLinks.length > 0) { List<WebElement> actualLinks = statusMessage[0].findElements(By.tagName("a")); for (int i = 0; i < expectedLinks.length; i++) { assertTrue(actualLinks.get(i).getAttribute("href").contains(expectedLinks[i])); } } } } /** * Set browser window to x width and y height. */ protected void setWindowSize(int x, int y) { Dimension d = new Dimension(x, y); browser.driver.manage().window().setSize(d); } /** * Encapsulates methods for handling Firefox {@code change} events. The methods can only handle one {@value CHANGE_EVENT} * event at a time and will only do something useful if test browser is Firefox. Note that the class does not check if * the {@value CHANGE_EVENT} event should be fired on the element. */ private class FirefoxChangeHandler { private static final String CHANGE_EVENT = "change"; /** * The attribute that the hook will modify to indicate if the {@value CHANGE_EVENT} event is detected. */ private static final String HOOK_ATTRIBUTE = "__change__"; /** * The maximum number of seconds required for all hardware (including slow ones) to fire the event. */ private static final int MAXIMUM_SECONDS_REQUIRED_FOR_ALL_CPUS_TO_FIRE_EVENT = 1; private final boolean isFirefox; FirefoxChangeHandler() { isFirefox = TestProperties.BROWSER_FIREFOX.equals(TestProperties.BROWSER); } /** * Returns true if the {@value CHANGE_EVENT} event hook has already been added. * Note that there can only be one hook (linked to a particular element) at a time for each page. */ private boolean isChangeEventHookAdded() { WebElement bodyElement = browser.driver.findElement(By.tagName("body")); return isExpectedCondition(ExpectedConditions.attributeToBeNotEmpty(bodyElement, HOOK_ATTRIBUTE)); } /** * Adds a {@value CHANGE_EVENT} event hook for the element. * The hook allows detection of the event required for {@link FirefoxChangeHandler#fireChangeEventIfNotFired}. * * @param element the element for which the hook will track whether the event is fired on the element * * @throws IllegalStateException if there is already a hook in the document */ private void addChangeEventHook(WebElement element) { if (!isFirefox) { return; } checkState(!isChangeEventHookAdded(), "The `%1$s` event hook can only be added once in the document.", CHANGE_EVENT); executeScript(ADD_CHANGE_EVENT_HOOK, element, CHANGE_EVENT, HOOK_ATTRIBUTE); } /** * Fires a {@value CHANGE_EVENT} event on the element if not already fired. * Requires a hook ({@link FirefoxChangeHandler#addChangeEventHook(WebElement)}) to be added before to detect * events. * Note that sometimes the {@value CHANGE_EVENT} event may need to be fired multiple times but this method only fires * one {@value CHANGE_EVENT} event in place of multiple {@value CHANGE_EVENT} events. This reinforces the notion that * events should not be fired manually so this method is to be avoided if possible. * * @param element the element for which the change event will be fired if it is not fired. * * @throws IllegalStateException if `change` event hook is not added * * @see FirefoxChangeHandler#isChangeEventNotFired() */ private void fireChangeEventIfNotFired(WebElement element) { if (!isFirefox) { return; } checkState(isChangeEventHookAdded(), "A `%s` hook has to be added previously to detect event firing.", CHANGE_EVENT); if (isChangeEventNotFired()) { fireChangeEvent(element); } removeHookAttribute(); } /** * Removes the attribute associated with a hook. */ private void removeHookAttribute() { executeScript(String.format("document.body.removeAttribute('%s');", HOOK_ATTRIBUTE)); } /** * Returns if a {@value CHANGE_EVENT} event has not been fired for the element to which the hook is associated. * Note that this only detects the presence of firing of {@value CHANGE_EVENT} events and not does not keep track of * how many {@value CHANGE_EVENT} events are fired. */ private boolean isChangeEventNotFired() { WebDriverWait wait = new WebDriverWait(browser.driver, MAXIMUM_SECONDS_REQUIRED_FOR_ALL_CPUS_TO_FIRE_EVENT); try { wait.until(ExpectedConditions.attributeContains(By.tagName("body"), HOOK_ATTRIBUTE, "true")); return false; } catch (TimeoutException e) { return true; } } /** * Fires the {@value CHANGE_EVENT} event on the element. * Note that this method should not usually be called because events should not be fired manually, * and may also result in unexpected <strong>multiple firings</strong> of the event. */ private void fireChangeEvent(WebElement element) { if (!isFirefox) { return; } // The `change` event is fired with bubbling enabled to simulate how browsers fire them. // See: https://developer.mozilla.org/en-US/docs/Web/Events/change executeScript("const event = new Event(arguments[1], {bubbles: true});" + "arguments[0].dispatchEvent(event);", element, CHANGE_EVENT); } } }