Blog Post

TestingSpot Blog
8 MIN READ

Test Automation and EasyRepro: 03 - Extending the EasyRepro Framework

Microsoft_Testing_Team's avatar
Microsoft_Testing_Team
Iron Contributor
Jan 11, 2021

The following is the third on a series of articles by @Ali Youssefi that we will be cross-posting into this Test Community Blog. These articles were first published by Ali in the Dynamics community but since the topic is very related with Testing it makes sense to publish here as well.

 

If you didn't get a chance to catch the first one of the series, please have a look here: 

 

Otherwise, please read ahead!

 

Summary

EasyRepro is an open source framework built upon Selenium allowing automated UI tests to be performed on a specific Dynamics 365 organization. This article will focus on reviewing how EasyRepro works with the Document Object Model otherwise known as the DOM. This will help us understand how we can extend the EasyRepro code for use with custom objects or areas of the platform not included in the framework at this time. We will wrap with a brief look at XPath and referencing elements from the DOM.

 

Getting Started

If you haven't already, check out the first article which covers getting familiar with EasyRepro and working with the source code locally. It covers cloning from GitHub to Azure DevOps then from Azure DevOps Git repository to a development machine. and reviews setting up dependencies and test settings to run a simple test. The second article goes into common actions performed by users when interacting with Dynamics 365 and the correlating EasyRepro commands. It wraps with covering common troubleshooting and debugging scenarios.

 

The EasyRepro framework contains an abundance of commands that should suit most of your needs. However you'll find that at times either these commands will need to be modified per your customizations or you may need to create your own custom commands for reuse among tests. 

Some example scenarios for modifying or creating custom commands may include:

  1. Using an ADFS Redirect Login

  2. Working with custom components and resources

  3. Working with strongly typed entity attributes

  4. Including navigation and functionality not present natively in the framework

 

These scenarios require an understanding of how EasyRepro interacts with Selenium to simulate user actions in a browser. This article will focus on a typical use case for extension: the Login process. We will describe common Selenium functionality and how to reference elements on the Document Object Model in a way to increase maintainability.

 

Sample Scenarios for Extending

Working with the Browser Driver

As we discussed earlier, your organization's login process may have a sign on page that a user will get redirected to once they input their username. This is where we begin our journey into extending the framework and utilizing Selenium.

Typical Single Sign On Page:

 

Custom method for a Single Sign On Page:

 

 

The image above shows a method designed to search through a sign in page DOM for input fields for credentials and submission. You will notice that we are working with the browser driver natively (args.Driver) and using methods not found in the sample EasyRepro unit tests. These include FindElement which is part of Selenium and WaitForPageToLoad which is part of EasyRepro's extension of Selenium. I'll touch on these at a high level now.

 

FindElement

FindElement is part of the Selenium.WebDriver assembly and is used to find an element in the DOM. I'll cover how to search through the DOM in the next section Extending and Working with XPath but I wanted to take this time to show how we will begin extending unit tests using Selenium natively.

 

The  method returns an IWebElement object that represents the element you want to work with. For instance if you want to work with the username textbox you can use FindElement to locate and return the username textbox. Once returned the framework can interact with it and perform actions such as input text or click.

 

Reference:

https://www.toolsqa.com/selenium-webdriver/c-sharp/findelement-and-findelements-commands-in-c/

 

WaitForPageToLoad

WaitForPageToLoad is part of the EasyRepro framework as a Selenium extension. This is key to point out that we are leveraging both EasyRepro and Selenium natively to achieve our desired result to login. This method waits a specific amount of time and checks for the document ready state. The time interval used to check can be adjusted if needed.

 

SendKeys and Clear

SendKeys is used to send keystrokes to an element on the page. This can be a complete string such as an email or a single keypress. This can be used to send the username and password to your sign in page. It also can be used to send the Enter or Tab keypress to move to the next field or submit.

Clear as it sounds is used to remove any sort of input that may already exist in an element. This is useful if your sign in page attempts to automatically input credentials.

Both of these methods are part of the IWebElement shown above. Other useful properties of IWebElement include TextSelected and GetAttribute.

 

Understanding how to extend element references

Considerations need to be made when designing unit tests to help reduce the maintenance work needed if something changes. For instance when referencing custom HTML web resources or even the ADFS Redirect from above, think how a change to an element in the DOM could propagate across some of all of your unit tests. One way to control maintenance is to centralize commonly used references into proxy objects that can hide the underlying mechanics from the test designer. This is exactly how the EasyRepro framework handles references and when extending can leverage the same approach. In this section we will cover the files used by the framework to reference DOM elements and show how to extend them to include references to our custom login page.

 

The App Element Reference File

The Microsoft.Dynamics365.UIAutomation.Api.UCI project uses the ElementReference.cs file as well as another file called AppElementReference.cs. The ElementReference file looks to have been brought over from the classic interface. What's unique about each is how they reference elements in the DOM which I'll cover in the next section. For now let's focus on reviewing the AppElementReference.cs file which is located in the DTO folder of the Microsoft.Dynamics365.UIAutomation.Api.UCI project.

 

 

 

Inside of the AppElementReference file are two objects used by EasyRepro to represent and locate elements: The AppReference and AppElements classes.

 

The AppReference class

The AppReference class includes sub classes that represent the objects used by EasyRepro, specifically the WebClient object, to connect to DOM elements. This allows the framework to standardize how the search for a particular container or element is performed. Centralizing the reference to the DOM elements will allow the test designer to focus on writing commands against common objects and not specifying the precise location of an element in the DOM.

 

The AppElement class

The AppElement class is a comma delimited key value pair consisting of the reference object property as a key and the XPath command as the value. The key represents the property name in the class object inside of AppReference while the value is the XPath location of the element in the DOM.

I highly suggest reviewing the AppElement class when extending EasyRepro as it shows recommended ways to locate and reference elements on the DOM. In the next section we will discuss the different ways you can locate elements including XPath.

 

Referencing elements in the Document Object Model

References to objects generally fall into four patterns:

  1. Resolving via Control Name

  2. Resolving via XPath

  3. Resolving by Element ID

  4. Resolving by CSS Class

 

In this article we will focus on XPath which is what is primarily used by the EasyRepro framework for the Dynamics 365 platform. However its key to understand each of the approaches for referencing as they can be used for customizations such as web resources, custom controls, etc.

 

Resolve with Control Name

This will search the DOM for elements with a specific name which is an attribute on the element node. This is not used by EasyRepro to my knowledge. Elements can be found by their name by using By.TagName with the FindElement method.

 

Resolve with Element ID

This will attempt to find an element by its unique identifier. For instance a textbox on the login form maybe identified as 'txtUserName'. Assuming this element ID is unique we could search for this particular element by an ID and return an IWebElement representation. An example from the Microsoft.Dynamics365.UIAutomation.Api.UCI project is shown below showing usage with the timeline control.

 

Definition:

 

 

 

Usage by WebClient:

 

 

Elements can be found by their ID by using By.Id with the FindElement method. 

 

Resolve with CSS Class

This allows the ability to search by the CSS class defined on the element. Be aware that this can return multiple elements due to the nature of CSS class. There is no usage of this in EasyRepro but again this could be helpful for customizations. Elements can be found by their CSS class name by using By.Class with the FindElement method.

 

Resolve with XPath

XPath allows us to work quickly and efficiently to search for a known element or path within the DOM. Key functions include the contains method which allow to search node attributes or values. For instance when you review the DOM of a form you'll notice attributes such as data-id or static text housed within a span element. Coupling this attribute with the html tag can result in a surprisingly accurate way to locate an element. I suggest leveraging the current element class as well as this link from W3 Schools that goes into the schema of XPath.

Going back to an earlier section let's review how XPath along with the AppElementReference.cs file can help standardize element location.

 

Using XPath in the ADFS Redirect Login Method

Going back to our original example for the ADFS login method below you'll see an example of referencing the DOM elements using XPath directly with the Selenium objects driver and By.XPath. Consider the below two images:

 

Without a static representation of XPath:

 

 

Using static classes to represent XPath queries:

 

 

 

Both of these methods work and perform exactly the same. However the second method provides increased supportability if and when the login page goes through changes. For instance consider if the id of the textbox to input your username changes from txtUserName to txtLoginId. Also what if this txtUserName XPath query is propagated across hundreds or more unit tests?

 

Creating custom reference objects

Let's put what we have learned to use by creating a reference to our custom login page. Start by adding a class to the AppReference object and title it AdfsLogin. Inside this class declare string properties that will be used as input for your organization's login page. Typical inputs include username, password and a submit button. Here is an example:

 

 

 

NOTE: While this document demonstrates how to add to the AppElementReference.cs file I would suggest extending this outside of the core files as customizations will have to be merged with any updates from the framework.

 

Once the static class we want to use in our unit tests has been created we now need to add the XPath references to the AppElement class. Below is an image showing the key value pair discussed in the AppElement section above. The key correlates to the string value of the AdfsLogin class while the value is the XPath directive for our elements.

 

 

As shown in the image for Login_UserId we are searching the DOM for an input element with the id attribute of 'txtUserName'. XPath can be used to search for any attribute on the DOM element and can return a single value or multiple depending on what you're searching for.

 

 

Next Steps

Custom Components and Web Resources

PowerApps Control Framework controls and web resources are customizations that represent great options to learn and extend the EasyRepro framework. Try locating the container for the PCF controls and HTML web resources and work towards creating custom objects representing the elements for each as described above.

 

Conclusion

In this article we discussed reasons why we will may need to extend the EasyRepro framework and some techniques in doing so. We explored working with Selenium objects and creating references to help us when creating unit tests. Finally we put this in an example for working with a ADFS Redirect page on login.

 

Thank you again for reading! Please let me know in the comments how your journey is going!

 

 

Updated Feb 11, 2021
Version 5.0
  • Priyadharrsini's avatar
    Priyadharrsini
    Copper Contributor

    Hi Team,

     

    I am getting the below error after edge browser & webdriver update. Any assistance regarding this could be of great help.

     

    Error: System.MissingMethod Exception : Method not found : ‘Void OpenQA.Selenium.Edge.EdgeOptions.set_UseInPrivateBrowsing(Boolean)

     

    Expected behavior:
    Expected behavior is, a new Edge browser to launch in "Private" mode.

     

    NuGet Packages Version:

    Selenium Webdriver - 3.141.0

    Selenium.Support - 3.141.0

    Selenium.Webdriver.MSEdgeDriver – 119.0.2151.42(Latest stable)

    Edge browser & Webdriver.exe – 119.0.2151.44

     

     

     

     

    Steps tried for resolution:

    1. Added edgeoptions for inprivate browsing.
    2. Added additionalcapabilities option.
    3. Tried adding the missing method in Edgeoption class but it is read-only
    4. In Browseroptions.cs file, added options for Inprivate browsing in ‘ToEdge’ method
    5. Browser and Webdriver versions are compatible with each other
  • gvsunil's avatar
    gvsunil
    Copper Contributor

    Hi Team,

     

    I am trying to Extend the LoginDetails and WebClient but i am getting the following error.
    Please find the below screenshot.
    ErrorMessage: " System.MissingMethodException : Method not found: 'Boolean Microsoft.Dynamics365.UIAutomation.Browser.SeleniumExtensions.IsVisible(OpenQA.Selenium.IWebDriver, OpenQA.Selenium.By)'."

    Any idea what could be the problem.

    LoginDetails page Extension class code.

    using System;
    using System.Collections.Generic;
    using System.Text;
    using System.Linq;
    using System.Threading;
    using System.Security;
    using Microsoft.Dynamics365.UIAutomation.Browser;
    using Microsoft.Dynamics365.UIAutomation.Api;
    using OpenQA.Selenium;
    using OpenQA.Selenium.Support.Extensions;
    using OpenQA.Selenium.Support.Events;
    using System.Diagnostics;

    namespace LD.Mellodirect.Integration.QA.CRMUIDataMapping.Pages
    {
    public class LoginPage : LoginDialog
    {
    public string[] OnlineDomains { get; set; }
    //public InteractiveBrowser browser;
    public LoginPage(InteractiveBrowser browser) : base(browser)
    {
    this.OnlineDomains = Constants.Xrm.XrmDomains;
    //this.browser = browser;
    }


    public BrowserCommandResult<LoginResult> DirectCRMLogin(Uri uri, SecureString userName, SecureString passWord)
    {
    return base.Execute(GetBrowserOptions("Login"), this.D365Login, uri, userName, passWord, default(Action<LoginRedirectEventArgs>));
    }

    private LoginResult D365Login(IWebDriver driver, Uri uri, SecureString username, SecureString password,Action<LoginRedirectEventArgs> redirection)
    {
    bool online = !(this.OnlineDomains != null && !this.OnlineDomains.Any(d => uri.Host.EndsWith(d)));
    driver.Navigate().GoToUrl(uri);
    bool redirect = false;
    if (online)
    {
    if (driver.IsVisible(By.Id("use_another_account_link")))
    driver.ClickWhenAvailable(By.Id("use_another_account_link"));
    driver.WaitUntilAvailable(By.XPath(Elements.Xpath[Reference.Login.UserId]),
    $"The Office 365 sign in page did not return the expected result and the user '{username}' could not be signed in.");

    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.UserId])).SendKeys(username.ToUnsecureString());
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.UserId])).SendKeys(Keys.Tab);
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.UserId])).SendKeys(Keys.Enter);

    Thread.Sleep(1000);

    if (driver.IsVisible(By.Id("aadTitle")))
    {
    driver.FindElement(By.Id("aadTitle")).Click(true);
    }

    Thread.Sleep(1000);
    if (redirection != null)
    {
    Thread.Sleep(3000);
    VerifyDefaultPageLoaded(username, password, driver);
    redirect = true;
    }
    else
    {
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.LoginPassword])).SendKeys(password.ToUnsecureString());
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.LoginPassword])).SendKeys(Keys.Tab);
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.LoginPassword])).Submit();

    Thread.Sleep(2000);

    if (driver.IsVisible(By.XPath(Elements.Xpath[Reference.Login.StaySignedIn])))
    {
    driver.ClickWhenAvailable(By.XPath(Elements.Xpath[Reference.Login.StaySignedIn]));

    if (driver.HasElement(By.XPath(Elements.Xpath[Reference.Login.StaySignedIn])))
    driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.StaySignedIn])).Submit();
    }

    driver.WaitUntilVisible(By.XPath("//div[contains(@data-id,'topBar') or contains(@id,'crmTopBar')]")
    , new TimeSpan(0, 0, 60),
    e =>
    {
    driver.WaitForPageToLoad();
    }
    );
    }
    }
    return redirect ? LoginResult.Redirect : LoginResult.Success;
    }

    public void VerifyDefaultPageLoaded(SecureString userName,SecureString password,IWebDriver driver)
    {
    var d = driver;
    d.FindElement(By.Id("passwordInput")).SendKeys(password.ToUnsecureString());
    d.ClickWhenAvailable(By.Id("submitButton"), new TimeSpan(0,0,30));
    d.WaitUntilVisible(By.XPath("//div[contains(@data-id,'topBar') or contains(@id,'crmTopBar')]")
    , new TimeSpan(0,0,60),
    e =>
    {
    d.WaitForPageToLoad();
    },
    f => { throw new Exception("Login page failed."); }
    );
    }

    internal BrowserCommandOptions GetBrowserOptions(string commandName)
    {
    BrowserCommandOptions options = new BrowserCommandOptions(Constants.DefaultTraceSource,
    commandName,
    0,
    0,
    null,
    true,
    typeof(NoSuchElementException), typeof(StaleElementReferenceException));
    return options;
    }

    }
    }


    WebClient Extension Code:

    using System;
    using System.Collections.Generic;
    using System.Text;
    using System.Security;
    using System.Web;
    using Microsoft.Dynamics365.UIAutomation.Browser;
    using Microsoft.Dynamics365.UIAutomation.Api.UCI;
    using OpenQA.Selenium;
    using Microsoft.Dynamics365.UIAutomation.Api.UCI.DTO;
    using OpenQA.Selenium.Interactions;
    using System.Linq;
    using System.Threading;
    using System.Diagnostics;
    using OtpNet;

    namespace LD.Mellodirect.Integration.QA.CRMUIDataMapping.Pages
    {
    public class LoginClient : WebClient
    {
    public LoginClient(BrowserOptions options):base(options)
    {
    base.Browser = new InteractiveBrowser(options);
    base.OnlineDomains = Constants.Xrm.XrmDomains;
    base.ClientSessionId = Guid.NewGuid();
    }

    internal BrowserCommandOptions GetOptions(string commandName)
    {
    return new BrowserCommandOptions(Constants.DefaultTraceSource,
    commandName,
    Constants.DefaultRetryAttempts,
    Constants.DefaultRetryDelay,
    null,
    true,
    typeof(NoSuchElementException), typeof(StaleElementReferenceException));
    }

    private bool EnterUserName(IWebDriver driver,SecureString username)
    {
    var input = driver.WaitUntilAvailable(By.XPath(Elements.Xpath[Reference.Login.UserId]), new TimeSpan(0, 0, 30));
    if (input == null)
    return false;

    input.SendKeys(username.ToUnsecureString());
    input.SendKeys(Keys.Enter);
    return true;
    }

    private static void EnterPassword(IWebDriver driver, SecureString password)
    {
    var input = driver.FindElement(By.XPath(Elements.Xpath[Reference.Login.LoginPassword]));
    input.SendKeys(password.ToUnsecureString());
    input.Submit();
    }

    internal bool WaitForMainPage(TimeSpan timeout, string errorMessage)
    => WaitForMainPage(timeout, null, (driver) => throw new InvalidOperationException(errorMessage));

    internal bool WaitForMainPage(TimeSpan? timeout = null, Action<IWebDriver> successCallback = null, Action<IWebDriver> failureCallback = null)
    {
    IWebDriver driver = Browser.Driver;
    TimeSpan waittimeout = timeout ?? Constants.DefaultTimeout;
    successCallback = successCallback ?? (
    _=>
    {
    bool isUCI = driver.HasElement(By.XPath(Elements.Xpath[Reference.Login.CrmUCIMainPage]));
    if (isUCI)
    driver.WaitForTransaction();
    });
    var xpathToMainPage = By.XPath(Elements.Xpath[Reference.Login.CrmMainPage]);
    var element = driver.WaitUntilAvailable(xpathToMainPage, waittimeout, successCallback, failureCallback);
    return element != null;

    }

    private static bool ClickStaySignedIn(IWebDriver driver)
    {
    var xpath = By.XPath(Elements.Xpath[Reference.Login.StaySignedIn]);
    IWebElement element = null;

    if(driver.IsVisible(xpath))
    {
    element = driver.FindElement(xpath);
    element.Click();
    }
    return element != null;
    }

    private static string GenerateOneTimeCode(SecureString mfaSecretKey)
    {
    string key = mfaSecretKey?.ToUnsecureString();
    byte[] base32Bytes = Base32Encoding.ToBytes(key);

    var totp = new Totp(base32Bytes);
    var result = totp.ComputeTotp();
    return result;
    }

    private static void SwitchToMainFrame(IWebDriver driver)
    {
    driver.WaitForPageToLoad();
    driver.SwitchTo().Frame(0);
    driver.WaitForPageToLoad();
    }

    private static void SwitchToDefaultContent(IWebDriver driver)
    {
    SwitchToMainFrame(driver);

    driver.SwitchTo().DefaultContent();
    }

    private static IWebElement GetOtcInput(IWebDriver driver)
    => driver.WaitUntilAvailable(By.XPath(Elements.Xpath[Reference.Login.OneTimeCode]), TimeSpan.FromSeconds(2));

    private void SetInputValue(IWebDriver driver,IWebElement input,string value,TimeSpan? thinktime = null)
    {
    input.Clear();
    input.Click();
    input.SendKeys(Keys.Control + "a");
    input.SendKeys(Keys.Control + "a");
    input.SendKeys(Keys.Backspace);
    input.SendKeys(value);

    var getvalue = input.GetAttribute("value");
    if (!getvalue.Equals(value))
    throw new InvalidOperationException($"Timeout after 10 seconds. Expected: {value}. Actual: {input.GetAttribute("value")}");
    }

    private bool EnterOneTimeCode(IWebDriver driver,SecureString mfaSecretKey)
    {
    try
    {
    IWebElement input = GetOtcInput(driver);
    if (input == null)
    return true;
    if (mfaSecretKey == null)
    throw new InvalidOperationException("The application is wait for the OTC but your MFA-SecretKey is not set. Please check your configuration.");
    var oneTimeCode = GenerateOneTimeCode(mfaSecretKey);
    SetInputValue(driver, input, oneTimeCode, TimeSpan.FromSeconds(1.0));
    input.Submit();
    return true;
    }
    catch (Exception e)
    {
    var message = $"An Error occur entering OTC. Exception: {e.Message}";
    Trace.TraceInformation(message);
    throw new InvalidOperationException(message, e);
    }
    }

    private bool IsUserAlreadyLogged() => WaitForMainPage(TimeSpan.FromSeconds(10.0));

    public BrowserCommandResult<LoginResult> D365Login(Uri orgUri, SecureString username, SecureString password, SecureString mfaSecretKey = null, Action<LoginRedirectEventArgs> redirectAction = null)
    {
    return Execute(GetOptions("Login"), D365Login, orgUri, username, password, mfaSecretKey, redirectAction);
    }

    private LoginResult D365Login(IWebDriver driver,Uri uri,SecureString username,SecureString password,SecureString mfaSecretKey = null,Action<LoginRedirectEventArgs> redirectAction = null)
    {
    bool online = !(OnlineDomains != null && !OnlineDomains.Any(d => uri.Host.EndsWith(d)));
    driver.Navigate().GoToUrl(uri);

    if (!online)
    return LoginResult.Success;
    var idAnotherAccount = By.Id("use_another_account_link");
    if(driver.IsVisible(idAnotherAccount))
    driver.FindElement(idAnotherAccount).Click();

    bool waitingForOtc = false;
    bool success = EnterUserName(driver, username);
    if(!success)
    {
    var isUserAlreadyLogged = IsUserAlreadyLogged();
    if (isUserAlreadyLogged)
    {
    SwitchToDefaultContent(driver);
    return LoginResult.Success;
    }

    base.Browser.ThinkTime(1000);

    if (!waitingForOtc)
    throw new Exception($"Login page failed. {Reference.Login.UserId} not found.");
    }

    if(!waitingForOtc)
    {
    var idAadTitle = By.Id("aadTile");
    if (driver.IsVisible(idAadTitle))
    driver.FindElement(idAadTitle).Click();

    base.Browser.ThinkTime(1000);

    if(redirectAction != null)
    {
    base.Browser.ThinkTime(3000);
    //redirectAction.Invoke(new base.ADFSLoginAction({ username,password,driver}));
    return LoginResult.Redirect;
    }

    EnterPassword(driver, password);
    //base.Browser.ThinkTime(1000);
    success = ClickStaySignedIn(driver) || IsUserAlreadyLogged();
    }

    int attempts = 0;
    bool entered = false;
    if(mfaSecretKey != null)
    {
    do
    {
    entered = EnterOneTimeCode(driver, mfaSecretKey);
    success = ClickStaySignedIn(driver) || IsUserAlreadyLogged();
    attempts++;
    }
    while (!success && attempts <= Constants.DefaultRetryAttempts);
    }

    if (entered && !success)
    {
    throw new InvalidOperationException("Somthing went wrong entering the OTC. Please check the MFA-SecretKey in configuration.");
    }

    return success ? LoginResult.Success : LoginResult.Failure;
    }
    }
    }
    Thanks & Regards,

    G.V.Sunil