diff --git a/src/main/java/io/appium/java_client/internal/ElementHelpers.java b/src/main/java/io/appium/java_client/internal/ElementHelpers.java new file mode 100644 index 000000000..dfe474153 --- /dev/null +++ b/src/main/java/io/appium/java_client/internal/ElementHelpers.java @@ -0,0 +1,337 @@ +package io.appium.java_client.internal; + +import io.appium.java_client.proxy.HasMethodCallListeners; +import org.jspecify.annotations.Nullable; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.WrapsDriver; +import org.openqa.selenium.WrapsElement; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +/** Internal helpers for unwrapping element and Selenium decorator wrappers. */ +public final class ElementHelpers { + private static final String DECORATED_INTERFACE_NAME = + "org.openqa.selenium.support.decorators.Decorated"; + private static final int MAX_UNALLOCATED_DEPTH = 4; + private static final Set CIRCULAR_SENTINEL = Collections.emptySet(); + + private static final ClassValue DECORATED_ACCESS = new ClassValue<>() { + @Override + protected DecoratedAccess computeValue(Class type) { + Class decoratedInterface = findInInterfaces( + type, iface -> DECORATED_INTERFACE_NAME.equals(iface.getName()) ? iface : null + ); + if (decoratedInterface == null) { + return DecoratedAccess.NONE; + } + Method getOriginal = findMethodOrNull(decoratedInterface, "getOriginal"); + if (getOriginal == null) { + return DecoratedAccess.NONE; + } + return new DecoratedAccess(getOriginal, findMethodOrNull(decoratedInterface, "getDecorator")); + } + }; + + private static final ClassValue DECORATED_DRIVER_METHOD = new ClassValue<>() { + @Override + protected MethodHolder computeValue(Class type) { + return new MethodHolder(findMethodOrNull(type, "getDecoratedDriver")); + } + }; + + private ElementHelpers() { + } + + /** Unwraps nested element and Selenium decorator wrappers. + * @param element the candidate object to unwrap + * @return the innermost unwrapped object, or null */ + @Nullable + public static Object unwrapElement(@Nullable Object element) { + Object current = element; + Set visited = null; + for (int depth = 0; current != null; depth++) { + visited = recordVisited(current, depth, visited); + if (visited == CIRCULAR_SENTINEL) { + break; + } + Object next = unwrapSingleElementStep(current); + if (next == null || next == current) { + break; + } + current = next; + } + return current; + } + + /** Checks whether two references resolve to equal underlying elements. + * @param first the first element or wrapper + * @param second the second element or wrapper + * @return true if both references resolve to equal underlying elements */ + public static boolean sameElement(@Nullable Object first, @Nullable Object second) { + if (first == second) { + return first != null; + } + if (first == null || second == null) { + return false; + } + Object unwrappedFirst = unwrapElement(first); + Object unwrappedSecond = unwrapElement(second); + if (unwrappedFirst == unwrappedSecond) { + return unwrappedFirst != null; + } + if (unwrappedFirst instanceof HasMethodCallListeners + && !(unwrappedSecond instanceof HasMethodCallListeners)) { + return unwrappedFirst.equals(unwrappedSecond); + } + if (!(unwrappedFirst instanceof HasMethodCallListeners) + && unwrappedSecond instanceof HasMethodCallListeners) { + return unwrappedSecond.equals(unwrappedFirst); + } + return Objects.equals(unwrappedFirst, unwrappedSecond); + } + + /** Walks wrapped search contexts in priority order and returns the first non-null match. + * @param candidate type to match + * @param extracted result type + * @param searchContext root search context + * @param targetType required type on the candidate and any decorated target + * @param extractor function extracting a value from each matching candidate + * @return the first non-null extracted value, or empty */ + public static Optional findInSearchContextChain( + @Nullable SearchContext searchContext, + Class targetType, + Function extractor + ) { + return Optional.ofNullable(doFindInSearchContextChain(searchContext, targetType, extractor, 0, null)); + } + + /** Resolves a public method callable on the given target class or its public interfaces. + * @param targetClass the runtime target class + * @param method the intercepted method + * @return a public callable method, or null */ + @Nullable + public static Method resolveTargetMethod(Class targetClass, Method method) { + if (method.getDeclaringClass().isAssignableFrom(targetClass) + && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + return method; + } + Class[] parameterTypes = method.getParameterTypes(); + Method interfaceMethod = findInInterfaces(targetClass, iface -> Modifier.isPublic(iface.getModifiers()) + ? findMethodOrNull(iface, method.getName(), parameterTypes) + : null); + if (interfaceMethod != null) { + return interfaceMethod; + } + if (Modifier.isPublic(targetClass.getModifiers())) { + return findMethodOrNull(targetClass, method.getName(), parameterTypes); + } + return null; + } + + @Nullable + private static Object extractDecoratedOriginal(@Nullable Object candidate) { + if (candidate == null) { + return null; + } + Method getOriginal = DECORATED_ACCESS.get(candidate.getClass()).getOriginal; + if (getOriginal == null) { + return null; + } + try { + return getOriginal.invoke(candidate); + } catch (ReflectiveOperationException e) { + return null; + } + } + + @Nullable + private static SearchContext extractDecoratedDriver(@Nullable Object candidate) { + if (candidate == null) { + return null; + } + Method getDecorator = DECORATED_ACCESS.get(candidate.getClass()).getDecorator; + if (getDecorator == null) { + return null; + } + try { + Object decorator = getDecorator.invoke(candidate); + if (decorator == null) { + return null; + } + Method getDecoratedDriver = DECORATED_DRIVER_METHOD.get(decorator.getClass()).method; + if (getDecoratedDriver == null) { + return null; + } + Object decoratedDriver = getDecoratedDriver.invoke(decorator); + if (decoratedDriver instanceof SearchContext) { + return (SearchContext) decoratedDriver; + } + if (decoratedDriver != null) { + Object originalDriver = extractDecoratedOriginal(decoratedDriver); + if (originalDriver instanceof SearchContext) { + return (SearchContext) originalDriver; + } + } + } catch (ReflectiveOperationException e) { + return null; + } + return null; + } + + @Nullable + private static R doFindInSearchContextChain( + @Nullable SearchContext searchContext, + Class targetType, + Function extractor, + int depth, + @Nullable Set visited + ) { + if (searchContext == null) { + return null; + } + Set activeVisited = recordVisited(searchContext, depth, visited); + if (activeVisited == CIRCULAR_SENTINEL) { + return null; + } + Object decoratedOriginal = extractDecoratedOriginal(searchContext); + if (targetType.isInstance(searchContext) + && (decoratedOriginal == null || targetType.isInstance(decoratedOriginal))) { + R matched = extractor.apply(targetType.cast(searchContext)); + if (matched != null) { + return matched; + } + } + int nextDepth = depth + 1; + if (searchContext instanceof WrapsDriver) { + WebDriver wrappedDriver = ((WrapsDriver) searchContext).getWrappedDriver(); + if (wrappedDriver != searchContext) { + R fromDriver = doFindInSearchContextChain( + wrappedDriver, targetType, extractor, nextDepth, activeVisited + ); + if (fromDriver != null) { + return fromDriver; + } + } + } + if (searchContext instanceof WrapsElement) { + WebElement wrappedElement = ((WrapsElement) searchContext).getWrappedElement(); + if (wrappedElement != searchContext) { + R fromElement = doFindInSearchContextChain( + wrappedElement, targetType, extractor, nextDepth, activeVisited + ); + if (fromElement != null) { + return fromElement; + } + } + } + if (decoratedOriginal instanceof SearchContext && decoratedOriginal != searchContext) { + R fromOriginal = doFindInSearchContextChain( + (SearchContext) decoratedOriginal, targetType, extractor, nextDepth, activeVisited + ); + if (fromOriginal != null) { + return fromOriginal; + } + } + SearchContext decoratedDriver = extractDecoratedDriver(searchContext); + if (decoratedDriver != null && decoratedDriver != searchContext) { + return doFindInSearchContextChain( + decoratedDriver, targetType, extractor, nextDepth, activeVisited + ); + } + return null; + } + + @Nullable + private static Set recordVisited(Object candidate, int depth, @Nullable Set visited) { + if (depth < MAX_UNALLOCATED_DEPTH) { + return visited; + } + Set active = visited != null ? visited : Collections.newSetFromMap(new IdentityHashMap<>()); + return active.add(candidate) ? active : CIRCULAR_SENTINEL; + } + + @Nullable + private static Object unwrapSingleElementStep(Object current) { + if (current instanceof WrapsElement) { + WebElement wrapped = ((WrapsElement) current).getWrappedElement(); + if (wrapped != null && wrapped != current) { + return wrapped; + } + } + Object decoratedOriginal = extractDecoratedOriginal(current); + if (decoratedOriginal instanceof WebElement && decoratedOriginal != current) { + return decoratedOriginal; + } + return null; + } + + @Nullable + private static Method findMethodOrNull(Class owner, String name, Class... parameterTypes) { + try { + return owner.getMethod(name, parameterTypes); + } catch (NoSuchMethodException e) { + return null; + } + } + + @Nullable + private static R findInInterfaces(@Nullable Class type, Function, @Nullable R> mapper) { + for (Class current = type; current != null && !Object.class.equals(current); + current = current.getSuperclass()) { + for (Class iface : current.getInterfaces()) { + R found = findOnInterfaceHierarchy(iface, mapper); + if (found != null) { + return found; + } + } + } + return null; + } + + @Nullable + private static R findOnInterfaceHierarchy(Class iface, Function, @Nullable R> mapper) { + R direct = mapper.apply(iface); + if (direct != null) { + return direct; + } + for (Class parent : iface.getInterfaces()) { + R found = findOnInterfaceHierarchy(parent, mapper); + if (found != null) { + return found; + } + } + return null; + } + + private static final class DecoratedAccess { + private static final DecoratedAccess NONE = new DecoratedAccess(null, null); + + @Nullable + private final Method getOriginal; + @Nullable + private final Method getDecorator; + + private DecoratedAccess(@Nullable Method getOriginal, @Nullable Method getDecorator) { + this.getOriginal = getOriginal; + this.getDecorator = getDecorator; + } + } + + private static final class MethodHolder { + @Nullable + private final Method method; + + private MethodHolder(@Nullable Method method) { + this.method = method; + } + } +} diff --git a/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java b/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java index f87c7f583..55d8c06c3 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java @@ -20,7 +20,6 @@ import io.appium.java_client.pagefactory.bys.ContentType; import io.appium.java_client.pagefactory.locator.CacheableLocator; import io.appium.java_client.pagefactory.locator.FurtherRoots; -import io.appium.java_client.remote.AppiumWebElement; import io.appium.java_client.support.pagefactory.ByAll; import io.appium.java_client.support.pagefactory.ByChained; import io.appium.java_client.support.ui.FluentWait; @@ -37,11 +36,11 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; +import static io.appium.java_client.internal.ElementHelpers.sameElement; import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; import static io.appium.java_client.pagefactory.ThrowableUtil.isInvalidSelectorRootCause; import static io.appium.java_client.pagefactory.ThrowableUtil.isStaleElementReferenceException; @@ -257,16 +256,6 @@ private static By[] alternativeLocators(By by) { return null; } - private static boolean sameRoot(WebElement left, WebElement right) { - if (left == right) { - return true; - } - if (left instanceof AppiumWebElement && right instanceof AppiumWebElement) { - return Objects.equals(((AppiumWebElement) left).getId(), ((AppiumWebElement) right).getId()); - } - return left.equals(right); - } - /** * Alternate locators captured for one content type. */ @@ -341,7 +330,7 @@ private WebElement pull() { continue; } WebElement firstMatch = matches.get(0); - if (!sameRoot(firstMatch, resolvedRoot)) { + if (!sameElement(firstMatch, resolvedRoot)) { return firstMatch; } } diff --git a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java index 94b737ff7..67712d758 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; +import static io.appium.java_client.internal.ElementHelpers.findInSearchContextChain; import static io.appium.java_client.internal.Preconditions.checkNotNull; import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedProxy; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackObjectFromSearchContext; @@ -226,14 +227,18 @@ private static WeakReference requireWebDriverReference(SearchContext } @Nullable - private String readStringCapability(SearchContext searchContext, String capName) { - if (searchContext == null) { - return null; - } - return unpackObjectFromSearchContext(searchContext, HasCapabilities.class) - .map(HasCapabilities::getCapabilities) - .map(caps -> CapabilityHelpers.getCapability(caps, capName, String.class)) - .orElse(null); + private static String readStringCapability(@Nullable SearchContext searchContext, String capName) { + return findInSearchContextChain( + searchContext, + HasCapabilities.class, + capsHolder -> extractStringCapability(capsHolder, capName) + ).orElse(null); + } + + @Nullable + private static String extractStringCapability(HasCapabilities capsHolder, String capName) { + var caps = capsHolder.getCapabilities(); + return caps == null ? null : CapabilityHelpers.getCapability(caps, capName, String.class); } private static final class ElementFieldDecorator extends DefaultFieldDecorator { @@ -265,7 +270,12 @@ protected WebElement proxyForLocator(ClassLoader ignored, ElementLocator locator @Override @SuppressWarnings("unchecked") protected List proxyForListLocator(ClassLoader ignored, ElementLocator locator) { - ElementListInterceptor elementInterceptor = new ElementListInterceptor(locator); + return proxyForListLocator(locator, false); + } + + @SuppressWarnings("unchecked") + private List proxyForListLocator(ElementLocator locator, boolean adaptToAppiumElement) { + ElementListInterceptor elementInterceptor = new ElementListInterceptor(locator, adaptToAppiumElement); return getEnhancedProxy(ArrayList.class, elementInterceptor); } @@ -299,7 +309,9 @@ private Object decorateElement( if (isElement) { return proxyForLocator(null, locator); } - return proxyForListLocator(null, locator); + boolean adaptToAppiumElement = listElementClasses.stream() + .anyMatch(AppiumWebElement.class::isAssignableFrom); + return proxyForListLocator(locator, adaptToAppiumElement); } } diff --git a/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java b/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java index 94cd74808..d5e8e8a6a 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java @@ -23,8 +23,12 @@ import java.lang.ref.WeakReference; import java.lang.reflect.Method; +import java.util.Arrays; +import static io.appium.java_client.internal.ElementHelpers.resolveTargetMethod; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; /** * Intercepts requests to {@link WebElement}. @@ -41,16 +45,33 @@ public ElementInterceptor(ElementLocator locator, WeakReference drive super(locator, driver); } + ElementInterceptor(WebElement cachedElement) { + super( + new StaticElementLocator(cachedElement), + new WeakReference<>(unpackWebDriverFromSearchContext(cachedElement)) + ); + } + @Override protected Object getObject(WebElement element, Method method, Object[] args) throws Throwable { try { - Method methodToInvoke = method; - if (!method.getDeclaringClass().isInstance(element)) { - // Selenium decorators may expose WebElement through an interface proxy. - methodToInvoke = WebElement.class.getMethod(method.getName(), method.getParameterTypes()); + Object target = element; + Method resolved = resolveTargetMethod(target.getClass(), method); + if (resolved == null) { + Object unwrapped = unwrapElement(element); + if (unwrapped != null && unwrapped != element) { + target = unwrapped; + resolved = resolveTargetMethod(target.getClass(), method); + } + } + if (resolved == null) { + throw new NoSuchMethodException( + target.getClass().getName() + "." + method.getName() + + Arrays.toString(method.getParameterTypes()) + ); } - return methodToInvoke.invoke(element, args); + return resolved.invoke(target, args); } catch (Throwable t) { throw extractReadableException(t); } diff --git a/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java b/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java index 4fdc62378..dbae38332 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java @@ -17,18 +17,23 @@ package io.appium.java_client.pagefactory; import io.appium.java_client.pagefactory.interceptors.InterceptorOfAListOfElements; +import io.appium.java_client.remote.AppiumWebElement; import io.appium.java_client.support.pagefactory.ElementLocator; +import org.jspecify.annotations.Nullable; import org.openqa.selenium.WebElement; import java.lang.reflect.Method; import java.util.List; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; +import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedProxy; /** * Intercepts requests to the list of {@link WebElement}. */ public class ElementListInterceptor extends InterceptorOfAListOfElements { + private final boolean adaptToAppiumElement; /** * Creates a new interceptor. @@ -36,16 +41,55 @@ public class ElementListInterceptor extends InterceptorOfAListOfElements { * @param locator the locator of the elements */ public ElementListInterceptor(ElementLocator locator) { + this(locator, false); + } + + ElementListInterceptor(ElementLocator locator, boolean adaptToAppiumElement) { super(locator); + this.adaptToAppiumElement = adaptToAppiumElement; } @Override protected Object getObject(List elements, Method method, Object[] args) throws Throwable { try { + if (!adaptToAppiumElement) { + return method.invoke(elements, args); + } + String methodName = method.getName(); + if ("size".equals(methodName) || "isEmpty".equals(methodName) || "clear".equals(methodName)) { + return method.invoke(elements, args); + } + if ("get".equals(methodName) && args != null && args.length == 1 && args[0] instanceof Integer) { + Object item = method.invoke(elements, args); + return item instanceof WebElement ? adaptIfNeeded((WebElement) item) : item; + } + adaptInPlace(elements); return method.invoke(elements, args); } catch (Throwable t) { throw extractReadableException(t); } } + private static void adaptInPlace(List elements) { + for (int i = 0; i < elements.size(); i++) { + WebElement element = elements.get(i); + WebElement adapted = adaptIfNeeded(element); + if (adapted != element) { + elements.set(i, adapted); + } + } + } + + @Nullable + private static WebElement adaptIfNeeded(@Nullable WebElement element) { + if (element == null || element instanceof AppiumWebElement) { + return element; + } + Object unwrapped = unwrapElement(element); + if (unwrapped == element) { + return element; + } + return getEnhancedProxy(AppiumWebElement.class, new ElementInterceptor(element)); + } + } diff --git a/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java b/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java index 69de8a51c..6a0f4b27a 100644 --- a/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java +++ b/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java @@ -29,9 +29,12 @@ import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Supplier; +import static io.appium.java_client.internal.ElementHelpers.sameElement; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; /** @@ -85,10 +88,10 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl return resolved.toString(); } if ("hashCode".equals(method.getName()) && (args == null || args.length == 0)) { - return resolved.hashCode(); + return Objects.hashCode(unwrapElement(resolved)); } if ("equals".equals(method.getName()) && args != null && args.length == 1) { - return same(proxy, args[0]); + return proxy == args[0] || sameElement(resolved, args[0]); } try { return method.invoke(resolved, args); @@ -129,16 +132,6 @@ private List findElements(By by) { return found; } - private boolean same(Object proxy, Object other) { - if (proxy == other) { - return true; - } - if (other instanceof WrapsElement) { - other = ((WrapsElement) other).getWrappedElement(); - } - return resolved == other || resolved.equals(other); - } - private static boolean isLookup(Method method, Object[] args, String name) { return name.equals(method.getName()) && args != null diff --git a/src/main/java/io/appium/java_client/pagefactory/StaticElementLocator.java b/src/main/java/io/appium/java_client/pagefactory/StaticElementLocator.java new file mode 100644 index 000000000..233a6c560 --- /dev/null +++ b/src/main/java/io/appium/java_client/pagefactory/StaticElementLocator.java @@ -0,0 +1,29 @@ +package io.appium.java_client.pagefactory; + +import io.appium.java_client.support.pagefactory.ElementLocator; +import org.openqa.selenium.WebElement; + +import java.util.List; + +final class StaticElementLocator implements ElementLocator { + private final WebElement element; + + StaticElementLocator(WebElement element) { + this.element = element; + } + + @Override + public WebElement findElement() { + return element; + } + + @Override + public List findElements() { + return List.of(element); + } + + @Override + public String toString() { + return element.toString(); + } +} diff --git a/src/main/java/io/appium/java_client/pagefactory/interceptors/InterceptorOfASingleElement.java b/src/main/java/io/appium/java_client/pagefactory/interceptors/InterceptorOfASingleElement.java index 5aee0246e..e04046bd5 100644 --- a/src/main/java/io/appium/java_client/pagefactory/interceptors/InterceptorOfASingleElement.java +++ b/src/main/java/io/appium/java_client/pagefactory/interceptors/InterceptorOfASingleElement.java @@ -17,7 +17,6 @@ package io.appium.java_client.pagefactory.interceptors; import io.appium.java_client.proxy.MethodCallListener; -import io.appium.java_client.remote.AppiumWebElement; import io.appium.java_client.support.pagefactory.ElementLocator; import org.jspecify.annotations.Nullable; import org.openqa.selenium.WebDriver; @@ -29,6 +28,9 @@ import java.util.Objects; import java.util.concurrent.Callable; +import static io.appium.java_client.internal.ElementHelpers.sameElement; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; + /** Base class of interceptors of method calls on a single element. */ public abstract class InterceptorOfASingleElement implements MethodCallListener { /** The locator used to find the element. */ @@ -60,37 +62,35 @@ public InterceptorOfASingleElement( */ protected abstract Object getObject(WebElement element, Method method, Object[] args) throws Throwable; - private static boolean areElementsEqual(Object we1, Object we2) { - if (!(we1 instanceof AppiumWebElement) || !(we2 instanceof AppiumWebElement)) { - return false; - } - - return we1 == we2 - || (Objects.equals(((AppiumWebElement) we1).getId(), ((AppiumWebElement) we2).getId())); - } - @Override public Object call(Object obj, Method method, Object[] args, Callable original) throws Throwable { if (locator == null) { return original.call(); } - if (method.getName().equals("toString") && args.length == 0) { + if ("toString".equals(method.getName()) && args.length == 0) { return locator.toString(); } + if ("equals".equals(method.getName()) && args.length == 1 && obj == args[0]) { + return true; + } + if (Object.class == method.getDeclaringClass()) { return original.call(); } if (WrapsDriver.class.isAssignableFrom(method.getDeclaringClass()) - && method.getName().equals("getWrappedDriver")) { + && "getWrappedDriver".equals(method.getName())) { return driverReference.get(); } WebElement realElement = locator.findElement(); if ("equals".equals(method.getName()) && args.length == 1) { - return areElementsEqual(realElement, args[0]); + return sameElement(realElement, args[0]); + } + if ("hashCode".equals(method.getName()) && args.length == 0) { + return Objects.hashCode(unwrapElement(realElement)); } return getObject(realElement, method, args); } diff --git a/src/main/java/io/appium/java_client/pagefactory/utils/WebDriverUnpackUtility.java b/src/main/java/io/appium/java_client/pagefactory/utils/WebDriverUnpackUtility.java index 0ed8fde45..cd0936a64 100644 --- a/src/main/java/io/appium/java_client/pagefactory/utils/WebDriverUnpackUtility.java +++ b/src/main/java/io/appium/java_client/pagefactory/utils/WebDriverUnpackUtility.java @@ -17,17 +17,21 @@ package io.appium.java_client.pagefactory.utils; import io.appium.java_client.HasBrowserCheck; +import io.appium.java_client.NoSuchContextException; import io.appium.java_client.pagefactory.bys.ContentType; import io.appium.java_client.remote.SupportsContextSwitching; import org.jspecify.annotations.Nullable; import org.openqa.selenium.SearchContext; +import org.openqa.selenium.UnsupportedCommandException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WrapsDriver; import org.openqa.selenium.WrapsElement; import java.util.Optional; +import java.util.function.Function; import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; +import static io.appium.java_client.internal.ElementHelpers.findInSearchContextChain; import static io.appium.java_client.pagefactory.bys.ContentType.HTML_OR_DEFAULT; import static io.appium.java_client.pagefactory.bys.ContentType.NATIVE_MOBILE_SPECIFIC; import static java.util.Locale.ROOT; @@ -36,6 +40,7 @@ * Utilities to unpack drivers and related objects from a {@link SearchContext}. */ public final class WebDriverUnpackUtility { + private WebDriverUnpackUtility() { } @@ -52,24 +57,10 @@ private WebDriverUnpackUtility() { * @param cls interface whose instance is going to be extracted. * @return Either an instance of the given interface or Optional.empty(). */ - public static Optional unpackObjectFromSearchContext(@Nullable SearchContext searchContext, Class cls) { - if (searchContext == null) { - return Optional.empty(); - } - - if (cls.isAssignableFrom(searchContext.getClass())) { - return Optional.of(cls.cast(searchContext)); - } - if (searchContext instanceof WrapsDriver) { - return unpackObjectFromSearchContext(((WrapsDriver) searchContext).getWrappedDriver(), cls); - } - // Search context it is not only WebDriver. WebElement is search context too. - // AppiumWebElement implements WrapsDriver - if (searchContext instanceof WrapsElement) { - return unpackObjectFromSearchContext(((WrapsElement) searchContext).getWrappedElement(), cls); - } - - return Optional.empty(); + public static Optional unpackObjectFromSearchContext( + @Nullable SearchContext searchContext, Class cls + ) { + return findInSearchContextChain(searchContext, cls, Function.identity()); } /** @@ -112,11 +103,20 @@ public static ContentType getCurrentContentType(SearchContext context) { } var contextAware = unpackObjectFromSearchContext(context, SupportsContextSwitching.class); - if (contextAware.map(SupportsContextSwitching::getContext) + if (contextAware.map(WebDriverUnpackUtility::safeGetContext) .filter(c -> c.toUpperCase(ROOT).contains(NATIVE_CONTEXT)).isPresent()) { return NATIVE_MOBILE_SPECIFIC; } return HTML_OR_DEFAULT; } + + @Nullable + private static String safeGetContext(SupportsContextSwitching contextAware) { + try { + return contextAware.getContext(); + } catch (UnsupportedCommandException | NoSuchContextException e) { + return null; + } + } } diff --git a/src/main/java/io/appium/java_client/remote/AppiumWebElement.java b/src/main/java/io/appium/java_client/remote/AppiumWebElement.java index df40f5e62..b57898fa7 100644 --- a/src/main/java/io/appium/java_client/remote/AppiumWebElement.java +++ b/src/main/java/io/appium/java_client/remote/AppiumWebElement.java @@ -28,13 +28,13 @@ import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; import org.openqa.selenium.WrapsDriver; -import org.openqa.selenium.WrapsElement; import org.openqa.selenium.interactions.Coordinates; import org.openqa.selenium.interactions.Locatable; import java.util.List; import java.util.Map; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static io.appium.java_client.remote.DriverCommand.FIND_CHILD_ELEMENT; import static io.appium.java_client.remote.DriverCommand.FIND_CHILD_ELEMENTS; import static java.util.Objects.requireNonNull; @@ -245,17 +245,21 @@ protected Response execute(String command, Map parameters) { @Override public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } if (!(obj instanceof WebElement)) { return false; } - WebElement other = (WebElement) obj; - while (other instanceof WrapsElement) { - other = ((WrapsElement) other).getWrappedElement(); + Object other = unwrapElement(obj); + if (this == other) { + return true; } if (!(other instanceof AppiumWebElement)) { return false; } - return id.equals(((AppiumWebElement) other).id); + String thisId = getId(); + return thisId != null && thisId.equals(((AppiumWebElement) other).getId()); } @Override diff --git a/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java b/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java index d14c29d48..bf758641a 100644 --- a/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java +++ b/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java @@ -17,7 +17,6 @@ package io.appium.java_client.remote; import org.jspecify.annotations.Nullable; -import org.openqa.selenium.WrapsElement; import java.lang.reflect.Array; import java.util.ArrayList; @@ -27,6 +26,7 @@ import java.util.Map; import java.util.function.Function; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static java.util.stream.Collectors.toList; /** @@ -41,9 +41,7 @@ public Object apply(@Nullable Object arg) { return arg; } - while (arg instanceof WrapsElement) { - arg = ((WrapsElement) arg).getWrappedElement(); - } + arg = unwrapElement(arg); if (arg instanceof AppiumWebElement) { return Map.of(AppiumWebElement.ELEMENT_KEY, ((AppiumWebElement) arg).getId()); diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/SeleniumDecoratorCompatibilityTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/SeleniumDecoratorCompatibilityTest.java index e55c68738..da43192cc 100644 --- a/src/test/java/io/appium/java_client/pagefactory_tests/SeleniumDecoratorCompatibilityTest.java +++ b/src/test/java/io/appium/java_client/pagefactory_tests/SeleniumDecoratorCompatibilityTest.java @@ -16,24 +16,95 @@ package io.appium.java_client.pagefactory_tests; +import io.appium.java_client.AppiumBy; +import io.appium.java_client.HasBrowserCheck; import io.appium.java_client.events.stubs.EmptyWebDriver; import io.appium.java_client.events.stubs.StubWebElement; +import io.appium.java_client.internal.ElementHelpers; import io.appium.java_client.pagefactory.AndroidFindBy; import io.appium.java_client.pagefactory.AppiumFieldDecorator; +import io.appium.java_client.pagefactory.ElementListInterceptor; +import io.appium.java_client.pagefactory.SearchingWidgetRoot; +import io.appium.java_client.pagefactory.Widget; +import io.appium.java_client.pagefactory.bys.ContentType; +import io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility; +import io.appium.java_client.remote.AppiumWebElement; +import io.appium.java_client.remote.AutomationName; +import io.appium.java_client.remote.MobilePlatform; +import io.appium.java_client.remote.Response; +import io.appium.java_client.remote.SupportsContextSwitching; +import io.appium.java_client.support.CacheLookup; import io.appium.java_client.support.PageFactory; +import io.appium.java_client.support.pagefactory.ByChained; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.HasCapabilities; +import org.openqa.selenium.ImmutableCapabilities; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.NoSuchSessionException; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.Point; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.UnsupportedCommandException; import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; +import org.openqa.selenium.WrapsDriver; +import org.openqa.selenium.WrapsElement; +import org.openqa.selenium.interactions.Coordinates; +import org.openqa.selenium.interactions.Locatable; +import org.openqa.selenium.remote.CapabilityType; import org.openqa.selenium.support.events.EventFiringDecorator; import org.openqa.selenium.support.events.WebDriverListener; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static io.appium.java_client.remote.options.SupportsAutomationNameOption.AUTOMATION_NAME_OPTION; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class SeleniumDecoratorCompatibilityTest { @AndroidFindBy(id = "some_id") private WebElement element; + @AndroidFindBy(accessibility = "acc_id") + private AppiumWebElement appiumElement; + + @AndroidFindBy(id = "some_id") + private AppiumWebElement sameAppiumElement; + + @AndroidFindBy(id = "list_id") + private List appiumElements; + + @AndroidFindBy(id = "list_id") + private List webElements; + + @CacheLookup + @AndroidFindBy(id = "list_id") + private List cachedAppiumElements; + + @AndroidFindBy(id = "widget_root") + private SampleWidget widget; + + @AndroidFindBy(id = "widget_list") + private List widgets; + @Test public void canInvokeElementReturnedBySeleniumDecorator() { CountingDriver originalDriver = new CountingDriver(); @@ -47,16 +118,334 @@ public void canInvokeElementReturnedBySeleniumDecorator() { assertEquals(1, listener.clickCount); } - public static class CountingDriver extends EmptyWebDriver { + @Test + public void translatesMobileByAnnotationsThroughDecoratedDriverAndPartialCapabilitiesWrapper() { + CountingDriver originalDriver = new CountingDriver(); + WebDriver partialWrapper = new EmptyCapabilitiesDriverWrapper(originalDriver); + WebDriver decoratedDriver = new EventFiringDecorator<>(new CountingListener()).decorate(partialWrapper); + PageFactory.initElements(new AppiumFieldDecorator(decoratedDriver), this); + + element.click(); + appiumElement.click(); + + assertEquals( + List.of( + new ByChained(AppiumBy.id("some_id")), + new ByChained(AppiumBy.accessibilityId("acc_id")) + ), + originalDriver.usedLocators + ); + } + + @Test + public void canInvokeNonWebElementInterfaceMethodsOnDecoratedElement() { + CountingDriver originalDriver = new CountingDriver(); + WebDriver decoratedDriver = new EventFiringDecorator<>(new CountingListener()).decorate(originalDriver); + PageFactory.initElements(new AppiumFieldDecorator(decoratedDriver), this); + + assertSame(originalDriver.element.coordinates, ((Locatable) element).getCoordinates()); + assertArrayEquals( + originalDriver.element.screenshotBytes, + ((TakesScreenshot) element).getScreenshotAs(OutputType.BYTES) + ); + assertSame(decoratedDriver, ((WrapsDriver) element).getWrappedDriver()); + assertEquals(originalDriver.element.hashCode(), element.hashCode()); + } + + @Test + public void canInvokeAppiumWebElementMethodsAndCompareEqualityWithDecoratedElement() { + AppiumElementDriver originalDriver = new AppiumElementDriver(); + CountingListener listener = new CountingListener(); + WebDriver decoratedDriver = new EventFiringDecorator<>(listener).decorate(originalDriver); + PageFactory.initElements(new AppiumFieldDecorator(decoratedDriver), this); + + assertEquals("appium-el-1", appiumElement.getId()); + assertEquals( + Map.of(AppiumWebElement.ELEMENT_KEY, "appium-el-1"), + appiumElement.toJson() + ); + + WebElement decoratedFound = decoratedDriver.findElement(AppiumBy.id("some_id")); + assertEquals(appiumElement, decoratedFound); + assertEquals(decoratedFound, appiumElement); + assertEquals(appiumElement, originalDriver.primaryElement); + assertEquals(originalDriver.primaryElement, appiumElement); + assertEquals(appiumElement, sameAppiumElement); + + WebElement searchingRoot = SearchingWidgetRoot.wrap( + new ElementWrapper(decoratedFound), Collections::emptyIterator + ); + assertEquals(searchingRoot, appiumElement); + assertEquals(appiumElement, searchingRoot); + assertEquals(originalDriver.primaryElement.hashCode(), appiumElement.hashCode()); + assertEquals(originalDriver.primaryElement.hashCode(), decoratedFound.hashCode()); + assertEquals(originalDriver.primaryElement.hashCode(), searchingRoot.hashCode()); + + Set deduplicated = new HashSet<>( + List.of(appiumElement, decoratedFound, originalDriver.primaryElement, searchingRoot) + ); + assertEquals(1, deduplicated.size()); + + assertNotEquals(appiumElement, originalDriver.secondaryElement); + assertNotEquals(originalDriver.secondaryElement, appiumElement); + + AppiumWebElement unboundFirst = new AppiumWebElement(); + AppiumWebElement unboundSecond = new AppiumWebElement(); + assertEquals(unboundFirst, unboundFirst); + assertNotEquals(unboundFirst, unboundSecond); + assertNotEquals(unboundFirst, appiumElement); + assertNotEquals(appiumElement, unboundFirst); + assertEquals(2, new HashSet<>(List.of(unboundFirst, unboundSecond)).size()); + } + + @Test + public void selfEqualityShortCircuitsBeforeLocatingMissingElement() { + MissingElementDriver driver = new MissingElementDriver(); + PageFactory.initElements(new AppiumFieldDecorator(driver), this); + + assertEquals(element, element); + assertEquals(appiumElement, appiumElement); + assertEquals(0, driver.findElementCalls); + } + + @Test + public void preservesPlainWebElementListIdentityAndTypeCasts() { + CountingDriver plainDriver = new CountingDriver(); + PageFactory.initElements(new AppiumFieldDecorator(plainDriver), this); + + assertSame(webElements.get(0), webElements.get(0)); + CountingElement raw = (CountingElement) webElements.get(0); + assertSame(plainDriver.element, raw); + } + + @Test + public void readsCapabilitiesInSinglePassAndPreservesContentTypeSemantics() { + CountingDriver innerDriver = new CountingDriver(); + CountingCapabilitiesWrapper capsWrapper = new CountingCapabilitiesWrapper(innerDriver); + PageFactory.initElements(new AppiumFieldDecorator(capsWrapper), this); + assertEquals(2, capsWrapper.capabilitiesReads); + + CountingContextDriver webViewDriver = new CountingContextDriver("WEBVIEW_1", null); + WebDriver decoratedWebView = new EventFiringDecorator<>(new CountingListener()).decorate(webViewDriver); + assertEquals( + ContentType.HTML_OR_DEFAULT, + WebDriverUnpackUtility.getCurrentContentType(decoratedWebView) + ); + assertEquals(1, webViewDriver.getContextCalls); + + CountingContextDriver unsupportedDriver = new CountingContextDriver( + null, new UnsupportedCommandException("getContext unsupported") + ); + assertEquals( + ContentType.HTML_OR_DEFAULT, + WebDriverUnpackUtility.getCurrentContentType(unsupportedDriver) + ); + + CountingContextDriver deadSessionDriver = new CountingContextDriver( + null, new NoSuchSessionException("session terminated") + ); + assertThrows( + NoSuchSessionException.class, + () -> WebDriverUnpackUtility.getCurrentContentType(deadSessionDriver) + ); + + BrowserOverrideWrapper browserWrapper = new BrowserOverrideWrapper(innerDriver, true); + assertEquals( + ContentType.HTML_OR_DEFAULT, + WebDriverUnpackUtility.getCurrentContentType(browserWrapper) + ); + } + + @Test + public void canUseAppiumWebElementAndWebElementListsWithSeleniumDecorator() throws Throwable { + AppiumElementDriver originalDriver = new AppiumElementDriver(); + CountingListener listener = new CountingListener(); + WebDriver decoratedDriver = new EventFiringDecorator<>(listener).decorate(originalDriver); + PageFactory.initElements(new AppiumFieldDecorator(decoratedDriver), this); + + assertEquals(2, appiumElements.size()); + AppiumWebElement first = appiumElements.get(0); + first.click(); + + assertEquals(1, listener.clickCount); + assertEquals("appium-el-1", first.getId()); + assertEquals(originalDriver.primaryElement, first); + assertEquals(first, originalDriver.primaryElement); + + assertEquals(2, webElements.size()); + webElements.get(1).click(); + assertEquals(2, listener.clickCount); + + assertEquals(2, cachedAppiumElements.size()); + AppiumWebElement removed = cachedAppiumElements.remove(0); + assertEquals("appium-el-1", removed.getId()); + + List backingList = new ArrayList<>(decoratedDriver.findElements(AppiumBy.id("list_id"))); + ExposedElementListInterceptor listInterceptor = new ExposedElementListInterceptor(); + listInterceptor.invokeListMethod(backingList, List.class.getMethod("remove", int.class), new Object[]{0}); + assertEquals(1, backingList.size()); + listInterceptor.invokeListMethod(backingList, List.class.getMethod("clear"), new Object[0]); + assertTrue(backingList.isEmpty()); + } + + @Test + public void canUseWidgetsAndWidgetListsWithSeleniumDecorator() { + CountingDriver originalDriver = new CountingDriver(); + CountingListener listener = new CountingListener(); + WebDriver decoratedDriver = new EventFiringDecorator<>(listener).decorate(originalDriver); + PageFactory.initElements(new AppiumFieldDecorator(decoratedDriver), this); + + widget.tapChild(); + + assertEquals(1, originalDriver.element.childElement.clickCount); + assertEquals(1, listener.clickCount); + assertEquals( + List.of(new ByChained(AppiumBy.id("child_id"))), + originalDriver.element.usedChildLocators + ); + + assertEquals(1, widgets.size()); + widgets.get(0).tapChild(); + assertEquals(2, originalDriver.element.childElement.clickCount); + assertEquals(2, listener.clickCount); + } + + @Test + public void unpacksFromDualWrappersAndGuardsAgainstSelfReferentialCycles() { + CountingElement innerElement = new CountingElement(); + SearchContext dualWrapper = new DualDriverAndElementWrapper(null, innerElement); + Optional unpacked = WebDriverUnpackUtility.unpackObjectFromSearchContext( + dualWrapper, CountingElement.class + ); + assertTrue(unpacked.isPresent()); + assertSame(innerElement, unpacked.get()); + + CyclicSearchContext cyclic = new CyclicSearchContext(); + assertFalse(WebDriverUnpackUtility.unpackObjectFromSearchContext(cyclic, WebDriver.class).isPresent()); + assertSame(cyclic, ElementHelpers.unwrapElement(cyclic)); + } + + @Test + public void guardsDeepMultiNodeWrapperCyclesAndEvaluatesElementHelpersEquality() { + MutableSearchContextWrapper tail = new MutableSearchContextWrapper(null); + SearchContext chain = tail; + for (int i = 0; i < 6; i++) { + chain = new MutableSearchContextWrapper(chain); + } + tail.delegate = chain; + + assertFalse(ElementHelpers.findInSearchContextChain( + chain, WebDriver.class, candidate -> candidate + ).isPresent()); + assertNotNull(ElementHelpers.unwrapElement(chain)); + + AppiumWebElement unboundFirst = new AppiumWebElement(); + assertFalse(ElementHelpers.sameElement(null, null)); + assertFalse(ElementHelpers.sameElement(unboundFirst, null)); + assertTrue(ElementHelpers.sameElement(unboundFirst, unboundFirst)); + AppiumWebElement unboundSecond = new AppiumWebElement(); + assertFalse(ElementHelpers.sameElement(unboundFirst, unboundSecond)); + } + + @Test + public void resolvesCallableMethodsAcrossPackagePrivateTargetsAndSubInterfaces() throws Exception { + Method packagePrivateMethod = PackagePrivateTarget.class.getDeclaredMethod("customLabel"); + Method resolvedOnPackagePrivate = ElementHelpers.resolveTargetMethod( + PackagePrivateTarget.class, packagePrivateMethod + ); + assertNotNull(resolvedOnPackagePrivate); + assertEquals(BaseLabelProvider.class, resolvedOnPackagePrivate.getDeclaringClass()); + assertEquals("ok", resolvedOnPackagePrivate.invoke(new PackagePrivateTarget())); + + Method unrelatedMethod = String.class.getMethod("trim"); + assertNull(ElementHelpers.resolveTargetMethod(PackagePrivateTarget.class, unrelatedMethod)); + assertNotNull(ElementHelpers.resolveTargetMethod(CountingElement.class, + TakesScreenshot.class.getMethod("getScreenshotAs", OutputType.class))); + } + + public static class SampleWidget extends Widget { + @AndroidFindBy(id = "child_id") + private WebElement child; + + protected SampleWidget(WebElement element) { + super(element); + } + + public void tapChild() { + child.click(); + } + } + + public static class CountingDriver extends EmptyWebDriver implements HasBrowserCheck { private final CountingElement element = new CountingElement(); + private final List usedLocators = new ArrayList<>(); + + @Override + public Capabilities getCapabilities() { + return new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, MobilePlatform.ANDROID, + AUTOMATION_NAME_OPTION, AutomationName.ANDROID_UIAUTOMATOR2 + ); + } + + @Override + public boolean isBrowser() { + return false; + } + + @Override + public Response execute(String driverCommand, Map parameters) { + return null; + } + + @Override + public Response execute(String driverCommand) { + return null; + } @Override public StubWebElement findElement(By by) { + usedLocators.add(by); return element; } + + @Override + public List findElements(By by) { + usedLocators.add(by); + return List.of(element); + } + } + + public static class CountingElement extends StubWebElement implements Locatable, TakesScreenshot { + private final Coordinates coordinates = new StubCoordinates(); + private final byte[] screenshotBytes = new byte[]{1, 2, 3}; + private final List usedChildLocators = new ArrayList<>(); + private final CountingChildElement childElement = new CountingChildElement(); + private int clickCount; + + @Override + public void click() { + clickCount++; + } + + @Override + public Coordinates getCoordinates() { + return coordinates; + } + + @Override + public X getScreenshotAs(OutputType target) throws WebDriverException { + return target.convertFromPngBytes(screenshotBytes); + } + + @Override + public WebElement findElement(By by) { + usedChildLocators.add(by); + return childElement; + } } - public static class CountingElement extends StubWebElement { + public static class CountingChildElement extends StubWebElement { private int clickCount; @Override @@ -65,6 +454,213 @@ public void click() { } } + public static class StubCoordinates implements Coordinates { + @Override + public Point onScreen() { + return new Point(0, 0); + } + + @Override + public Point inViewPort() { + return new Point(0, 0); + } + + @Override + public Point onPage() { + return new Point(0, 0); + } + + @Override + public Object getAuxiliary() { + return "aux"; + } + } + + public static class BaseWebDriverStub implements WebDriver, HasCapabilities { + @Override + public void get(String url) { + } + + @Override + public String getCurrentUrl() { + return null; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public List findElements(By by) { + return List.of(); + } + + @Override + public WebElement findElement(By by) { + return null; + } + + @Override + public String getPageSource() { + return null; + } + + @Override + public void close() { + } + + @Override + public void quit() { + } + + @Override + public Set getWindowHandles() { + return Set.of(); + } + + @Override + public String getWindowHandle() { + return null; + } + + @Override + public TargetLocator switchTo() { + return null; + } + + @Override + public Navigation navigate() { + return null; + } + + @Override + public Options manage() { + return null; + } + + @Override + public Capabilities getCapabilities() { + return new ImmutableCapabilities(); + } + } + + public static class AppiumElementDriver extends BaseWebDriverStub implements HasBrowserCheck { + private final StubAppiumWebElement primaryElement = new StubAppiumWebElement("appium-el-1"); + private final StubAppiumWebElement secondaryElement = new StubAppiumWebElement("appium-el-2"); + + @Override + public Capabilities getCapabilities() { + return new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, MobilePlatform.ANDROID, + AUTOMATION_NAME_OPTION, AutomationName.ANDROID_UIAUTOMATOR2 + ); + } + + @Override + public boolean isBrowser() { + return false; + } + + @Override + public Response execute(String driverCommand, Map parameters) { + return null; + } + + @Override + public Response execute(String driverCommand) { + return null; + } + + @Override + public WebElement findElement(By by) { + return primaryElement; + } + + @Override + public List findElements(By by) { + return List.of(primaryElement, secondaryElement); + } + } + + public static class StubAppiumWebElement extends AppiumWebElement { + private int clickCount; + + public StubAppiumWebElement(String id) { + setId(id); + } + + @Override + public void click() { + clickCount++; + } + } + + public static class EmptyCapabilitiesDriverWrapper extends BaseWebDriverStub implements WrapsDriver { + private final WebDriver delegate; + + public EmptyCapabilitiesDriverWrapper(WebDriver delegate) { + this.delegate = delegate; + } + + @Override + public WebDriver getWrappedDriver() { + return delegate; + } + + @Override + public WebElement findElement(By by) { + return delegate.findElement(by); + } + + @Override + public List findElements(By by) { + return delegate.findElements(by); + } + } + + public static class DualDriverAndElementWrapper implements SearchContext, WrapsDriver, WrapsElement { + private final WebDriver driver; + private final WebElement element; + + public DualDriverAndElementWrapper(WebDriver driver, WebElement element) { + this.driver = driver; + this.element = element; + } + + @Override + public WebDriver getWrappedDriver() { + return driver; + } + + @Override + public WebElement getWrappedElement() { + return element; + } + + @Override + public List findElements(By by) { + return List.of(); + } + + @Override + public WebElement findElement(By by) { + return element; + } + } + + public static class CyclicSearchContext extends StubWebElement implements WrapsDriver, WrapsElement { + @Override + public WebDriver getWrappedDriver() { + return null; + } + + @Override + public WebElement getWrappedElement() { + return this; + } + } + public static class CountingListener implements WebDriverListener { private int clickCount; @@ -73,4 +669,147 @@ public void afterClick(WebElement element) { clickCount++; } } + + public static class ElementWrapper extends StubWebElement implements WrapsElement { + private final WebElement wrapped; + + public ElementWrapper(WebElement wrapped) { + this.wrapped = wrapped; + } + + @Override + public WebElement getWrappedElement() { + return wrapped; + } + } + + public static class MissingElementDriver extends CountingDriver { + private int findElementCalls; + + @Override + public StubWebElement findElement(By by) { + findElementCalls++; + throw new NoSuchElementException("missing"); + } + } + + public static class CountingCapabilitiesWrapper extends EmptyCapabilitiesDriverWrapper { + private int capabilitiesReads; + + public CountingCapabilitiesWrapper(WebDriver delegate) { + super(delegate); + } + + @Override + public Capabilities getCapabilities() { + capabilitiesReads++; + return new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, MobilePlatform.ANDROID, + AUTOMATION_NAME_OPTION, AutomationName.ANDROID_UIAUTOMATOR2 + ); + } + } + + public static class CountingContextDriver extends BaseWebDriverStub implements SupportsContextSwitching { + private final String contextValue; + private final RuntimeException errorToThrow; + private int getContextCalls; + + public CountingContextDriver(String contextValue, RuntimeException errorToThrow) { + this.contextValue = contextValue; + this.errorToThrow = errorToThrow; + } + + @Override + public Response execute(String driverCommand, Map parameters) { + return null; + } + + @Override + public Response execute(String driverCommand) { + return null; + } + + @Override + public WebDriver context(String name) { + return this; + } + + @Override + public Set getContextHandles() { + return Set.of(); + } + + @Override + public String getContext() { + getContextCalls++; + if (errorToThrow != null) { + throw errorToThrow; + } + return contextValue; + } + } + + public static class BrowserOverrideWrapper extends EmptyCapabilitiesDriverWrapper implements HasBrowserCheck { + private final boolean browser; + + public BrowserOverrideWrapper(WebDriver delegate, boolean browser) { + super(delegate); + this.browser = browser; + } + + @Override + public Response execute(String driverCommand, Map parameters) { + return null; + } + + @Override + public Response execute(String driverCommand) { + return null; + } + + @Override + public boolean isBrowser() { + return browser; + } + } + + public static class ExposedElementListInterceptor extends ElementListInterceptor { + public ExposedElementListInterceptor() { + super(null); + } + + public Object invokeListMethod( + List elements, Method method, Object[] args + ) throws Throwable { + return getObject(elements, method, args); + } + } + + public interface BaseLabelProvider { + String customLabel(); + } + + public interface PublicLabelExtension extends BaseLabelProvider { + } + + static final class PackagePrivateTarget implements PublicLabelExtension { + @Override + public String customLabel() { + return "ok"; + } + } + + static final class MutableSearchContextWrapper extends StubWebElement implements WrapsElement { + private SearchContext delegate; + + MutableSearchContextWrapper(SearchContext delegate) { + this.delegate = delegate; + } + + @Override + public WebElement getWrappedElement() { + return delegate instanceof WebElement ? (WebElement) delegate : null; + } + } }