From aac4d59bfcb3b53ad5a56d9f02e3ee3219e1b7ca Mon Sep 17 00:00:00 2001 From: Jim Evans Date: Mon, 4 Aug 2014 10:43:21 -0400 Subject: [PATCH] Implementing pluggable element locator factories for .NET PageFactory This change allows the user to specify a custom IElementLocatorFactory for locating elements when used with the PageFactory. This gives much more control over the algorithm used to locate elements, and allows the incorporation of things like retries or handling of specific exceptions. --- dotnet/src/support/GlobalSuppressions.cs | 4 +- .../DefaultElementLocatorFactory.cs | 93 +++++++++++ .../support/PageObjects/FindsByAttribute.cs | 12 +- .../PageObjects/IElementLocatorFactory.cs | 46 ++++++ dotnet/src/support/PageObjects/PageFactory.cs | 62 ++++++- .../RetryingElementLocatorFactory.cs | 153 ++++++++++++++++++ .../PageObjects/WebElementListProxy.cs | 12 +- .../support/PageObjects/WebElementProxy.cs | 26 +-- dotnet/src/support/WebDriver.Support.csproj | 3 + 9 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 dotnet/src/support/PageObjects/DefaultElementLocatorFactory.cs create mode 100644 dotnet/src/support/PageObjects/IElementLocatorFactory.cs create mode 100644 dotnet/src/support/PageObjects/RetryingElementLocatorFactory.cs diff --git a/dotnet/src/support/GlobalSuppressions.cs b/dotnet/src/support/GlobalSuppressions.cs index e46ac86aba200..fb60425b837f8 100644 --- a/dotnet/src/support/GlobalSuppressions.cs +++ b/dotnet/src/support/GlobalSuppressions.cs @@ -26,7 +26,6 @@ // "In Project Suppression File". // You do not need to add suppressions to this file manually. -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "OpenQA.Selenium.Support.PageObjects", Justification = "Number of namespace classes is subject to increase.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "1#", Scope = "member", Target = "OpenQA.Selenium.Support.Events.WebDriverNavigationEventArgs.#.ctor(OpenQA.Selenium.IWebDriver,System.String)", Justification = "Using string to preserve user input.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1056:UriPropertiesShouldNotBeStrings", Scope = "member", Target = "OpenQA.Selenium.Support.Events.WebDriverNavigationEventArgs.#Url", Justification = "Using string to preserve user input.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Scope = "member", Target = "OpenQA.Selenium.Support.Events.EventFiringWebDriver+EventFiringWebElement.#ParentDriver", Justification = "Method must be available for subclasses.")] @@ -72,3 +71,6 @@ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "OpenQA.Selenium.Support.UI.SelectElement.#.ctor(OpenQA.Selenium.IWebElement)", Justification = "WebDriver normalizes strings to lowercase.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1065:DoNotRaiseExceptionsInUnexpectedLocations", Scope = "member", Target = "OpenQA.Selenium.Support.UI.SelectElement.#SelectedOption", Justification = "Exception should be thrown in this property.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "OpenQA.Selenium.Support.UI.DefaultWait`1.#Until`1(System.Func`2)", Justification = "Analyzing and handling all exceptions that occur, so catching generic Exception is appropriate.")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "IJavaScriptExecutor", Scope = "member", Target = "OpenQA.Selenium.Support.Extensions.WebDriverExtensions.#ExecuteJavaScript`1(OpenQA.Selenium.IWebDriver,System.String,System.Object[])", Justification = "Interface name is correct")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "IHasCapabilities", Scope = "member", Target = "OpenQA.Selenium.Support.Extensions.WebDriverExtensions.#TakeScreenshot(OpenQA.Selenium.IWebDriver)", Justification = "Interface name is correct")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "ITakesScreenshot", Scope = "member", Target = "OpenQA.Selenium.Support.Extensions.WebDriverExtensions.#TakeScreenshot(OpenQA.Selenium.IWebDriver)", Justification = "Interface name is correct")] diff --git a/dotnet/src/support/PageObjects/DefaultElementLocatorFactory.cs b/dotnet/src/support/PageObjects/DefaultElementLocatorFactory.cs new file mode 100644 index 0000000000000..8c8770f9e8680 --- /dev/null +++ b/dotnet/src/support/PageObjects/DefaultElementLocatorFactory.cs @@ -0,0 +1,93 @@ +// +// Copyright 2014 Software Freedom Conservancy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace OpenQA.Selenium.Support.PageObjects +{ + /// + /// A default locator for elements for use with the . This locator + /// implements no retry logic for elements not being found, nor for elements being stale. + /// + public class DefaultElementLocatorFactory : IElementLocatorFactory + { + /// + /// Locates an element using the given and list of criteria. + /// + /// The object within which to search for an element. + /// The list of methods by which to search for the element. + /// An which is the first match under the desired criteria. + public IWebElement LocateElement(ISearchContext searchContext, IEnumerable bys) + { + if (searchContext == null) + { + throw new ArgumentNullException("searchContext", "searchContext may not be null"); + } + + if (bys == null) + { + throw new ArgumentNullException("bys", "List of criteria may not be null"); + } + + string errorString = null; + foreach (var by in bys) + { + try + { + return searchContext.FindElement(by); + } + catch (NoSuchElementException) + { + errorString = (errorString == null ? "Could not find element by: " : errorString + ", or: ") + by; + } + } + + throw new NoSuchElementException(errorString); + } + + /// + /// Locates a list of elements using the given and list of criteria. + /// + /// The object within which to search for elements. + /// The list of methods by which to search for the elements. + /// An list of all elements which match the desired criteria. + public ReadOnlyCollection LocateElements(ISearchContext searchContext, IEnumerable bys) + { + if (searchContext == null) + { + throw new ArgumentNullException("searchContext", "searchContext may not be null"); + } + + if (bys == null) + { + throw new ArgumentNullException("bys", "List of criteria may not be null"); + } + + List collection = new List(); + foreach (var by in bys) + { + ReadOnlyCollection list = searchContext.FindElements(by); + collection.AddRange(list); + } + + return collection.AsReadOnly(); + } + } +} diff --git a/dotnet/src/support/PageObjects/FindsByAttribute.cs b/dotnet/src/support/PageObjects/FindsByAttribute.cs index 76223ad3dd8af..94ed0412cccfa 100644 --- a/dotnet/src/support/PageObjects/FindsByAttribute.cs +++ b/dotnet/src/support/PageObjects/FindsByAttribute.cs @@ -32,7 +32,7 @@ namespace OpenQA.Selenium.Support.PageObjects /// to indicate how to find the elements. This attribute can be used to decorate fields and properties /// in your Page Object classes. The of the field or property must be either /// or IList{IWebElement}. Any other type will throw an - /// when is called. + /// when is called. /// /// /// @@ -152,6 +152,11 @@ internal By Finder /// if the first instance is greater than the second; otherwise, . public static bool operator >(FindsByAttribute one, FindsByAttribute two) { + if (one == null) + { + throw new ArgumentNullException("one", "Object to compare cannot be null"); + } + return one.CompareTo(two) > 0; } @@ -163,6 +168,11 @@ internal By Finder /// if the first instance is less than the second; otherwise, . public static bool operator <(FindsByAttribute one, FindsByAttribute two) { + if (one == null) + { + throw new ArgumentNullException("one", "Object to compare cannot be null"); + } + return one.CompareTo(two) < 0; } diff --git a/dotnet/src/support/PageObjects/IElementLocatorFactory.cs b/dotnet/src/support/PageObjects/IElementLocatorFactory.cs new file mode 100644 index 0000000000000..8f9a5ea5cc2a7 --- /dev/null +++ b/dotnet/src/support/PageObjects/IElementLocatorFactory.cs @@ -0,0 +1,46 @@ +// +// Copyright 2014 Software Freedom Conservancy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace OpenQA.Selenium.Support.PageObjects +{ + /// + /// Interface describing how elements are to be located by a + /// + public interface IElementLocatorFactory + { + /// + /// Locates an element using the given and list of criteria. + /// + /// The object within which to search for an element. + /// The list of methods by which to search for the element. + /// An which is the first match under the desired criteria. + IWebElement LocateElement(ISearchContext searchContext, IEnumerable bys); + + /// + /// Locates a list of elements using the given and list of criteria. + /// + /// The object within which to search for elements. + /// The list of methods by which to search for the elements. + /// An list of all elements which match the desired criteria. + ReadOnlyCollection LocateElements(ISearchContext searchContext, IEnumerable bys); + } +} diff --git a/dotnet/src/support/PageObjects/PageFactory.cs b/dotnet/src/support/PageObjects/PageFactory.cs index c337bfbf0fd0f..0cd186da4c8da 100644 --- a/dotnet/src/support/PageObjects/PageFactory.cs +++ b/dotnet/src/support/PageObjects/PageFactory.cs @@ -57,6 +57,31 @@ private PageFactory() /// or IList{IWebElement}. /// public static T InitElements(IWebDriver driver) + { + return InitElements(driver, new DefaultElementLocatorFactory()); + } + + /// + /// Initializes the elements in the Page Object with the given type. + /// + /// The of the Page Object class. + /// The instance used to populate the page. + /// The implementation that + /// determines how elements are located. + /// An instance of the Page Object class with the elements initialized. + /// + /// The class used in the argument must have a public constructor + /// that takes a single argument of type . This helps to enforce + /// best practices of the Page Object pattern, and encapsulates the driver into the Page + /// Object so that it can have no external WebDriver dependencies. + /// + /// + /// thrown if no constructor to the class can be found with a single IWebDriver argument + /// -or- + /// if a field or property decorated with the is not of type + /// or IList{IWebElement}. + /// + public static T InitElements(IWebDriver driver, IElementLocatorFactory locatorFactory) { T page = default(T); Type pageClassType = typeof(T); @@ -67,7 +92,7 @@ public static T InitElements(IWebDriver driver) } page = (T)ctor.Invoke(new object[] { driver }); - InitElements(driver, page); + InitElements(driver, page, locatorFactory); return page; } @@ -81,12 +106,35 @@ public static T InitElements(IWebDriver driver) /// or IList{IWebElement}. /// public static void InitElements(ISearchContext driver, object page) + { + InitElements(driver, page, new DefaultElementLocatorFactory()); + } + + /// + /// Initializes the elements in the Page Object. + /// + /// The driver used to find elements on the page. + /// The Page Object to be populated with elements. + /// The implementation that + /// determines how elements are located. + /// + /// thrown if a field or property decorated with the is not of type + /// or IList{IWebElement}. + /// + public static void InitElements(ISearchContext driver, object page, IElementLocatorFactory locatorFactory) { if (page == null) { throw new ArgumentNullException("page", "page cannot be null"); } + if (locatorFactory == null) + { + throw new ArgumentNullException("locatorFactory", "locatorFactory cannot be null"); + } + + // Get a list of all of the fields and properties (public and non-public [private, protected, etc.]) + // in the passed-in page object. Note that we walk the inheritance tree to get superclass members. var type = page.GetType(); var members = new List(); const BindingFlags PublicBindingOptions = BindingFlags.Instance | BindingFlags.Public; @@ -100,6 +148,8 @@ public static void InitElements(ISearchContext driver, object page) type = type.BaseType; } + // Examine each member, and if it is both marked with an appropriate attribute, and of + // the proper type, set the member's value to the appropriate type of proxy object. foreach (var member in members) { List bys = CreateLocatorList(member); @@ -112,7 +162,7 @@ public static void InitElements(ISearchContext driver, object page) var property = member as PropertyInfo; if (field != null) { - proxyObject = CreateProxyObject(field.FieldType, driver, bys, cache); + proxyObject = CreateProxyObject(field.FieldType, driver, bys, cache, locatorFactory); if (proxyObject == null) { throw new ArgumentException("Type of field '" + field.Name + "' is not IWebElement or IList"); @@ -122,7 +172,7 @@ public static void InitElements(ISearchContext driver, object page) } else if (property != null) { - proxyObject = CreateProxyObject(property.PropertyType, driver, bys, cache); + proxyObject = CreateProxyObject(property.PropertyType, driver, bys, cache, locatorFactory); if (proxyObject == null) { throw new ArgumentException("Type of property '" + property.Name + "' is not IWebElement or IList"); @@ -173,16 +223,16 @@ private static bool ShouldCacheLookup(MemberInfo member) return cache; } - private static object CreateProxyObject(Type memberType, ISearchContext driver, List bys, bool cache) + private static object CreateProxyObject(Type memberType, ISearchContext driver, List bys, bool cache, IElementLocatorFactory locatorFactory) { object proxyObject = null; if (memberType == typeof(IList)) { - proxyObject = new WebElementListProxy(driver, bys, cache); + proxyObject = new WebElementListProxy(driver, bys, cache, locatorFactory); } else if (memberType == typeof(IWebElement)) { - proxyObject = new WebElementProxy(driver, bys, cache); + proxyObject = new WebElementProxy(driver, bys, cache, locatorFactory); } return proxyObject; diff --git a/dotnet/src/support/PageObjects/RetryingElementLocatorFactory.cs b/dotnet/src/support/PageObjects/RetryingElementLocatorFactory.cs new file mode 100644 index 0000000000000..b2597094c3741 --- /dev/null +++ b/dotnet/src/support/PageObjects/RetryingElementLocatorFactory.cs @@ -0,0 +1,153 @@ +// +// Copyright 2014 Software Freedom Conservancy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading; + +namespace OpenQA.Selenium.Support.PageObjects +{ + /// + /// A locator for elements for use with the that retries locating + /// the element up to a timeout if the element is not found. + /// + public class RetryingElementLocatorFactory : IElementLocatorFactory + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultPollingInterval = TimeSpan.FromMilliseconds(500); + + private TimeSpan timeout; + private TimeSpan pollingInterval; + + /// + /// Initializes a new instance of the class. + /// + public RetryingElementLocatorFactory() + : this(DefaultTimeout) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The indicating how long the locator should + /// retry before timing out. + public RetryingElementLocatorFactory(TimeSpan timeout) + : this(timeout, DefaultPollingInterval) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The indicating how long the locator should + /// retry before timing out. + /// The indicating how often to poll + /// for the existence of the element. + public RetryingElementLocatorFactory(TimeSpan timeout, TimeSpan pollingInterval) + { + this.timeout = timeout; + this.pollingInterval = pollingInterval; + } + + /// + /// Locates an element using the given and list of criteria. + /// + /// The object within which to search for an element. + /// The list of methods by which to search for the element. + /// An which is the first match under the desired criteria. + public IWebElement LocateElement(ISearchContext searchContext, IEnumerable bys) + { + if (searchContext == null) + { + throw new ArgumentNullException("searchContext", "searchContext may not be null"); + } + + if (bys == null) + { + throw new ArgumentNullException("bys", "List of criteria may not be null"); + } + + string errorString = null; + DateTime endTime = DateTime.Now.Add(this.timeout); + bool timeoutReached = DateTime.Now > endTime; + while (!timeoutReached) + { + foreach (var by in bys) + { + try + { + return searchContext.FindElement(by); + } + catch (NoSuchElementException) + { + errorString = (errorString == null ? "Could not find element by: " : errorString + ", or: ") + by; + } + } + + timeoutReached = DateTime.Now > endTime; + if (!timeoutReached) + { + Thread.Sleep(this.pollingInterval); + } + } + + throw new NoSuchElementException(errorString); + } + + /// + /// Locates a list of elements using the given and list of criteria. + /// + /// The object within which to search for elements. + /// The list of methods by which to search for the elements. + /// An list of all elements which match the desired criteria. + public ReadOnlyCollection LocateElements(ISearchContext searchContext, IEnumerable bys) + { + if (searchContext == null) + { + throw new ArgumentNullException("searchContext", "searchContext may not be null"); + } + + if (bys == null) + { + throw new ArgumentNullException("bys", "List of criteria may not be null"); + } + + List collection = new List(); + DateTime endTime = DateTime.Now.Add(this.timeout); + bool timeoutReached = DateTime.Now > endTime; + while (!timeoutReached) + { + foreach (var by in bys) + { + ReadOnlyCollection list = searchContext.FindElements(by); + collection.AddRange(list); + } + + timeoutReached = collection.Count == 0 && DateTime.Now > endTime; + if (!timeoutReached) + { + Thread.Sleep(this.pollingInterval); + } + } + + return collection.AsReadOnly(); + } + } +} diff --git a/dotnet/src/support/PageObjects/WebElementListProxy.cs b/dotnet/src/support/PageObjects/WebElementListProxy.cs index 5c9f00b4e93fb..0a29f4f592fbe 100644 --- a/dotnet/src/support/PageObjects/WebElementListProxy.cs +++ b/dotnet/src/support/PageObjects/WebElementListProxy.cs @@ -29,6 +29,7 @@ namespace OpenQA.Selenium.Support.PageObjects /// internal class WebElementListProxy : IList { + private readonly IElementLocatorFactory locatorFactory; private readonly ISearchContext searchContext; private readonly IEnumerable bys; private readonly bool cache; @@ -40,8 +41,11 @@ internal class WebElementListProxy : IList /// The driver used to search for elements. /// The list of methods by which to search for the elements. /// to cache the lookup to the element; otherwise, . - internal WebElementListProxy(ISearchContext searchContext, IEnumerable bys, bool cache) + /// The implementation that + /// determines how elements are located. + internal WebElementListProxy(ISearchContext searchContext, IEnumerable bys, bool cache, IElementLocatorFactory locatorFactory) { + this.locatorFactory = locatorFactory; this.searchContext = searchContext; this.bys = bys; this.cache = cache; @@ -77,11 +81,7 @@ private List ElementList if (!this.cache || this.collection == null) { this.collection = new List(); - foreach (var by in this.bys) - { - ReadOnlyCollection list = this.searchContext.FindElements(by); - this.collection.AddRange(list); - } + this.collection.AddRange(this.locatorFactory.LocateElements(this.searchContext, this.bys)); } return this.collection; diff --git a/dotnet/src/support/PageObjects/WebElementProxy.cs b/dotnet/src/support/PageObjects/WebElementProxy.cs index f97503b269bf2..c0f9491a8158f 100644 --- a/dotnet/src/support/PageObjects/WebElementProxy.cs +++ b/dotnet/src/support/PageObjects/WebElementProxy.cs @@ -31,6 +31,7 @@ namespace OpenQA.Selenium.Support.PageObjects /// internal class WebElementProxy : IWebElement, ILocatable, IWrapsElement { + private readonly IElementLocatorFactory locatorFactory; private readonly ISearchContext searchContext; private readonly IEnumerable bys; private readonly bool cache; @@ -42,8 +43,11 @@ internal class WebElementProxy : IWebElement, ILocatable, IWrapsElement /// The driver used to search for elements. /// The list of methods by which to search for the element. /// to cache the lookup to the element; otherwise, . - internal WebElementProxy(ISearchContext searchContext, IEnumerable bys, bool cache) + /// The implementation that + /// determines how elements are located. + internal WebElementProxy(ISearchContext searchContext, IEnumerable bys, bool cache, IElementLocatorFactory locatorFactory) { + this.locatorFactory = locatorFactory; this.searchContext = searchContext; this.bys = bys; this.cache = cache; @@ -147,26 +151,12 @@ public IWebElement WrappedElement { get { - if (this.cache && this.cachedElement != null) + if (!this.cache || this.cachedElement == null) { - return this.cachedElement; + this.cachedElement = this.locatorFactory.LocateElement(this.searchContext, this.bys); } - string errorString = null; - foreach (var by in this.bys) - { - try - { - this.cachedElement = this.searchContext.FindElement(by); - return this.cachedElement; - } - catch (NoSuchElementException) - { - errorString = (errorString == null ? "Could not find element by: " : errorString + ", or: ") + by; - } - } - - throw new NoSuchElementException(errorString); + return this.cachedElement; } } diff --git a/dotnet/src/support/WebDriver.Support.csproj b/dotnet/src/support/WebDriver.Support.csproj index 17fc1d97ca6e0..98bdadb6ae277 100644 --- a/dotnet/src/support/WebDriver.Support.csproj +++ b/dotnet/src/support/WebDriver.Support.csproj @@ -81,9 +81,12 @@ + + +