Thursday, 4 August 2016

Finding elements by JavaScript


While trying to use JavaScript to speed up the test execution, many tests would start first operation (such as selecting one option from a combo box, clicking on a link or button and etc.) several minutes after the pages loaded. This is hard to tolerate when running tests from my PC and anyone can only wait several minutes to run a single line of code. 

In these cases, though CodedUI cannot step forward, instead a JavaScript can be run from command line of IE browser directly. BrowserWindow would execute JavaScript i.e. ExecuteScript() only after WaitForControlReady() returning true. Fortunately, developing JavaScript to replace native operations like Mouse.Click() could still be very helpful.


From time to time, CodedUI tests would fail to perform some very basic operations like clicking on a button or entering text to a text control. Sometimes click on a line/button of a web page, “Click” sound will be heard but the browser would simply do nothing. JavaScript would then be more effective when it operates directly on the element instead of via UI.

The key scripts  are listed below:
        #region JavaScript function names
        public const string FindByCssFunction = "querySelector";
        public const string FindByIdFunction = "getElementById";
        public const string FindFirstByCssFunction = "querySelectorAll";
        public const string FindFirstByClassFunction = "getElementsByClassName";
        public const string FindFirstByNameFunction = "getElementsByName";
        public const string FindFirstByTagFunction = "getElementsByTagName";
        #endregion

        public const string GetElementByIdScript = @"
var result = document.getElementById(arguments[0]);
if (result)
    return result;

var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
    var frame = frames[arguments[1]];
    if (frame.document)
        return frame.document.getElementById(arguments[0]);
    else
        return frame.contentWindow.document.getElementById(arguments[0]);
}

for(var i = 0; i < frames.length; i ++) {
    if (frames[i].document)
        result = frames[i].document.getElementById(arguments[0]);
    else
        result = frames[i].contentWindow.document.getElementById(arguments[0]);

    if (result) break;
}
return result;";

        public const string FrameGetElementByIdScript = @"
var result = arguments[0].contentDocument.getElementById(arguments[1]);
return result;";

        public const string GetFirstByCssScript = @"
var elements = document.querySelectorAll(arguments[0]);
if (elements.length)
    return elements[0];

var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
    var frame = frames[arguments[1]];
    if (frame.document)
        return frame.document.querySelectorAll(arguments[0]);
    else
        return frame.contentWindow.document.querySelectorAll(arguments[0]);
}

for(var i = 0; i < frames.length; i ++) {
    if (frames[i].document)
        elements = frames[i].document.querySelectorAll(arguments[0]);
    else
        elements = frames[i].contentWindow.document.querySelectorAll(arguments[0]);
if (elements.length)
    return elements[0];
}
return null;";

        public const string GetClickablePoint = @"
var element = arguments[0];
var absoluteLeft = element.width/2;
var absoluteTop = element.height/2;

do {
absoluteLeft += element.offsetLeft;
absoluteTop += element.offsetTop;
element = element.parentElement;
}while(element)

var result = new Array();
result[0] = Math.round(absoluteLeft).toString();
result[1] = Math.round(absoluteTop).toString();
return result;";

        public const string GetAttributeScript = @"try{{return arguments[0].getAttribute('{0}');}}catch(err){{return null;}}";

        public enum FindFirstMethod
        {
            ById,
            ByCSS,
            FirstByCSS,
            FirstByClass,
            FirstByName,
            FirstByTag,
        }

The web app under test uses multiple frames as container to show different panels. To search one element with its ID, The JavaScript “GetElementByIdScript” would first search the root document, then after getting all frames, would try to iterate all frames to see if the element with specified ID could be found within one of them. The “FrameGetElementByIdScript” is used to search a single frame only give element ID. The “GetFirstByCssScript” provides a more generic means to search element with CSS selectors which however would normally return an array and actually only the first one is expected. The “GetAttributeScript” is used to retrieve attribute from a given element, notice that when there is an error catched, it shall return “null”.

To reuse the above scripts with different methods (by ID, by Class, by Name and etc.), the FindFirstMethod enum is defined and the default value ById would be used as below:
public static HtmlControl FindControl(this BrowserWindow window, string locatorKey, FindFirstMethod method = FindFirstMethod.ById, string frameName = "body")
{
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    string script = null;
    switch (method)
    {
        case FindFirstMethod.ById:
            script = GetElementByIdScript;
            break;
        case FindFirstMethod.ByCSS:
            script = GetElementByIdScript.Replace(FindByIdFunction, FindByCssFunction);
            break;
        case FindFirstMethod.FirstByCSS:
            script = GetFirstByCssScript;
            break;
        case FindFirstMethod.FirstByClass:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByClassFunction);
            break;
        case FindFirstMethod.FirstByName:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByNameFunction);
            break;
        case FindFirstMethod.FirstByTag:
            script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByTagFunction);
            break;
        default:
            throw new NotSupportedException();
    }

    object result = null;
    Stopwatch watch = new Stopwatch();
    watch.Start();
    while (result == null && watch.ElapsedMilliseconds < 20 * 1000)
    {
        result = window.ExecuteScript(script, locatorKey, frameName);

        //To cope with the bug of BrowserWindow..ExecuteScript()
        var optionList = result as IList<object>;
        if (optionList != null)
        {
            var child = optionList.FirstOrDefault(o => o != null) as HtmlControl;
            result = (child == null || !child.Exists ) ? null : child.GetParent();
        }
    }
    return result as HtmlControl;
}
The script would then be finalized by replacing some keywords and run by a BrowserWindow instance. Noticeably, when trying to get a HtmlComboBox, CodedUI would wrongly return an array of its option children and that is why there is some special treatment to get a single HtmlControl.
CodedUI only defines BrowserWindow with virtual object ExecuteScript(string script, params object[] args), to make it easier to use, there are two helper methods introduced to target one specific control with or without extra parameters:

public static object RunScript(this HtmlControl control, string script)
{
    if (control == null)
        throw new Exception("Failed to locating the control?!");

    BrowserWindow window = control.TopParent as BrowserWindow;
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    return window.ExecuteScript(script, control);
}

public static object RunScript(this HtmlControl control, string script, params object[] extraArguments)
{
    if (control == null)
        throw new Exception("Failed to locating the control?!");

    BrowserWindow window = control.TopParent as BrowserWindow;
    if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
        throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");

    var len = extraArguments.Length;
    object[] arguments = new object[len + 1];
    arguments[0] = control;
    for (int i = 0; i < len; i++)
    {
        arguments[i + 1] = extraArguments[i];
    }

    return window.ExecuteScript(script, arguments);
}

Then some helper functions are quite straightforward:
public static string AttributeByScript(this HtmlControl control, string attributename)
{
    return control.RunScript(string.Format(GetAttributeScript, attributename)) as string;
}

public static string InnerText(this HtmlControl control)
{
    return control.RunScript("return arguments[0].innerText;") as string;
}

public static string InnerHTML(this HtmlControl control)
{
    return control.RunScript("return arguments[0].innerHtml;") as string;
}

public static string OuterHTML(this HtmlControl control)
{
    return control.RunScript("return arguments[0].outerHtml;") as string;
}

public static Point ClickablePointByScript(this HtmlControl control)
{
    object result = control.RunScript(GetClickablePoint);
    List<object> position = (List<object>)result;
    return new Point(int.Parse(position[0].ToString()), int.Parse(position[1].ToString()));
}
Their meanings are explained below:
  • SomeControl.AttributeByScript(attributename): to get Attribute of a control by attribute name;
  • SomeControl.InnerText(): would retrieve everything within the opening and closing tag of the control as innerText as used in section “Evaluating rows of a table”. It could be tuned to get displayed text.
  • SomeControl.InnerHtml()/OuterHtml(): returns InnerHtml and OuterHtml respectively.
  • SomeControl.ClickablePointByScript(): I have tried this to get the clickable point to avoid waiting CodedUI to be ready to click some link/button, but it doesn’t return before the target control is really ready.
The more useful methods are listed here:
public const bool HighLightControlBeforeOperation = true;
public static void ClickByScript(this HtmlControl control)
{
    control.ShowByScript();

    if (HighLightControlBeforeOperation)
        control.HighlightByScript();

    control.RunScript("arguments[0].click();");
}

public static void SetValue(this HtmlControl control, string valueString)
{
    control.RunScript("arguments[0].value = arguments[1];", valueString);
}

public static void ShowByScript(this HtmlControl control)
{
    control.RunScript("arguments[0].scrollIntoView(true);");
}

public const string DefaultHighlightStyle = "color: green; border: solid red; background-color: yellow;";
public static void HighlightByScript(this HtmlControl control)
{
    //*/ Highlight by script: changing the style of concerned element
    var oldStyle = control.AttributeByScript("style");

    control.RunScript("arguments[0].setAttribute('style', arguments[1]);", DefaultHighlightStyle);
    System.Threading.Thread.Sleep(DefaultHighlightTimeMillis);
    if (oldStyle != null)
    {
        control.RunScript("arguments[0].setAttribute('style', arguments[1]);", oldStyle);
    }
    else
        control.RunScript(string.Format("arguments[0].removeAttribute('style');"));
}
Their meanings are explained below:
  • SomeEdit.SetValue(valueString): is used to input text to Edit control even when it is not displayed yet.
  • SomeControl.ShowByScript(): would make the control visible for further operation/observation.
  • SomeControl.HighlightByScript(): to modify the style of the target control to make it highlighted for several seconds.
  • SomeControl.ClickByScript(): might be the most useful method to fix tests. The ShowByScript() would be called to make the control visible, then HighlightByScript() would be called to mark it outstanding before perfroming “someControl.click()”. This design is out of intention: by performing operations 3 times(scrolling once, changing styles twice), it is very unlikely to miss clicking on the target control.
To use these scripts are also quite simple, taken Click() for example: for some problematic Mouse.Click(someControl), just replacing them with “someControl.ClickByScript()” would make many failed tests passed.

Source: http://www.codeproject.com/Articles/876476/Tips-to-fix-CodedUI-tests