From 16438a02011e06fe348236ea8b57030e30331c49 Mon Sep 17 00:00:00 2001 From: Nacy Dev Date: Tue, 10 Nov 2026 08:34:07 +0700 Subject: [PATCH 1/3] fix: support Selenium WebDriverDecorator across AppiumFieldDecorator elements and widgets --- .../pagefactory/AppiumElementLocator.java | 15 +- .../pagefactory/AppiumFieldDecorator.java | 15 +- .../pagefactory/ElementInterceptor.java | 133 +++++- .../pagefactory/ElementListInterceptor.java | 11 +- .../SearchingWidgetRootHandler.java | 14 +- .../InterceptorOfASingleElement.java | 31 +- .../utils/WebDriverUnpackUtility.java | 227 ++++++++- .../java_client/remote/AppiumWebElement.java | 14 +- .../remote/WebElementToJsonConverter.java | 6 +- .../SeleniumDecoratorCompatibilityTest.java | 451 +++++++++++++++++- 10 files changed, 855 insertions(+), 62 deletions(-) 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..91164c413 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java @@ -46,6 +46,7 @@ import static io.appium.java_client.pagefactory.ThrowableUtil.isInvalidSelectorRootCause; import static io.appium.java_client.pagefactory.ThrowableUtil.isStaleElementReferenceException; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; import static java.lang.String.format; class AppiumElementLocator implements CacheableLocator { @@ -261,10 +262,18 @@ 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()); + Object unwrappedLeft = unwrapElement(left); + Object unwrappedRight = unwrapElement(right); + if (unwrappedLeft == unwrappedRight) { + return true; + } + if (unwrappedLeft instanceof AppiumWebElement && unwrappedRight instanceof AppiumWebElement) { + return Objects.equals( + ((AppiumWebElement) unwrappedLeft).getId(), + ((AppiumWebElement) unwrappedRight).getId() + ); } - return left.equals(right); + return Objects.equals(unwrappedLeft, unwrappedRight); } /** 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..996e750f3 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -230,12 +230,21 @@ 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)) + return unpackObjectFromSearchContext( + searchContext, + HasCapabilities.class, + capsHolder -> extractStringCapability(capsHolder, capName) != null + ) + .map(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 { private final AppiumElementLocatorFactory locatorFactory; private final WeakReference webDriverReference; 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..4b6b13ef6 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java @@ -18,13 +18,18 @@ import io.appium.java_client.pagefactory.interceptors.InterceptorOfASingleElement; import io.appium.java_client.support.pagefactory.ElementLocator; +import org.jspecify.annotations.Nullable; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import java.lang.ref.WeakReference; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.List; import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; /** * Intercepts requests to {@link WebElement}. @@ -41,18 +46,134 @@ 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()); - } - return methodToInvoke.invoke(element, args); + return invokeElementMethod(element, method, args); } catch (Throwable t) { throw extractReadableException(t); } } + + private static Object invokeElementMethod( + WebElement element, Method method, Object[] args + ) throws ReflectiveOperationException { + if (method.getDeclaringClass().isInstance(element)) { + return method.invoke(element, args); + } + + Method interfaceOrObjectMethod = findInterfaceOrObjectMethod( + element.getClass(), method.getName(), method.getParameterTypes() + ); + if (interfaceOrObjectMethod != null) { + return interfaceOrObjectMethod.invoke(element, args); + } + + Object unwrapped = unwrapElement(element); + if (unwrapped != null && unwrapped != element) { + if (method.getDeclaringClass().isInstance(unwrapped)) { + return method.invoke(unwrapped, args); + } + Method unwrappedInterfaceMethod = findInterfaceOrObjectMethod( + unwrapped.getClass(), method.getName(), method.getParameterTypes() + ); + if (unwrappedInterfaceMethod != null) { + return unwrappedInterfaceMethod.invoke(unwrapped, args); + } + return unwrapped.getClass() + .getMethod(method.getName(), method.getParameterTypes()) + .invoke(unwrapped, args); + } + + return element.getClass() + .getMethod(method.getName(), method.getParameterTypes()) + .invoke(element, args); + } + + @Nullable + private static Method findInterfaceOrObjectMethod( + Class targetClass, String name, Class[] parameterTypes + ) { + Method objectMethod = findDeclaredPublicMethod(Object.class, name, parameterTypes); + if (objectMethod != null) { + return objectMethod; + } + if (WebElement.class.isAssignableFrom(targetClass)) { + Method webElementMethod = findDeclaredPublicMethod(WebElement.class, name, parameterTypes); + if (webElementMethod != null) { + return webElementMethod; + } + } + for (Class current = targetClass; current != null && !Object.class.equals(current); + current = current.getSuperclass()) { + for (Class iface : current.getInterfaces()) { + Method found = findOnInterfaceHierarchy(iface, name, parameterTypes); + if (found != null) { + return found; + } + } + } + return null; + } + + @Nullable + private static Method findOnInterfaceHierarchy( + Class iface, String name, Class[] parameterTypes + ) { + if (Modifier.isPublic(iface.getModifiers())) { + Method direct = findDeclaredPublicMethod(iface, name, parameterTypes); + if (direct != null) { + return direct; + } + } + for (Class parent : iface.getInterfaces()) { + Method found = findOnInterfaceHierarchy(parent, name, parameterTypes); + if (found != null) { + return found; + } + } + return null; + } + + @Nullable + private static Method findDeclaredPublicMethod( + Class owner, String name, Class[] parameterTypes + ) { + try { + return owner.getMethod(name, parameterTypes); + } catch (NoSuchMethodException e) { + return null; + } + } + + private static final class StaticElementLocator implements ElementLocator { + private final WebElement element; + + private 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/ElementListInterceptor.java b/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java index 4fdc62378..4b26367ca 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java @@ -17,13 +17,16 @@ 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.openqa.selenium.WebElement; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.List; 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}. @@ -42,7 +45,13 @@ public ElementListInterceptor(ElementLocator locator) { @Override protected Object getObject(List elements, Method method, Object[] args) throws Throwable { try { - return method.invoke(elements, args); + List adapted = new ArrayList<>(elements.size()); + for (WebElement element : elements) { + adapted.add(element == null || element instanceof AppiumWebElement + ? element + : getEnhancedProxy(AppiumWebElement.class, new ElementInterceptor(element))); + } + return method.invoke(adapted, args); } catch (Throwable t) { throw extractReadableException(t); } 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..7cdb4a9f8 100644 --- a/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java +++ b/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java @@ -16,6 +16,7 @@ package io.appium.java_client.pagefactory; +import org.jspecify.annotations.Nullable; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebElement; @@ -29,10 +30,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.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; /** * Invocation handler for a {@link SearchingWidgetRoot} proxy. @@ -129,14 +132,13 @@ private List findElements(By by) { return found; } - private boolean same(Object proxy, Object other) { - if (proxy == other) { + private boolean same(Object proxy, @Nullable Object other) { + if (proxy == other || resolved == other) { return true; } - if (other instanceof WrapsElement) { - other = ((WrapsElement) other).getWrappedElement(); - } - return resolved == other || resolved.equals(other); + Object unwrappedResolved = unwrapElement(resolved); + Object unwrappedOther = unwrapElement(other); + return unwrappedResolved == unwrappedOther || Objects.equals(unwrappedResolved, unwrappedOther); } private static boolean isLookup(Method method, Object[] args, String name) { 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..eb554c1df 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 @@ -29,6 +29,8 @@ import java.util.Objects; import java.util.concurrent.Callable; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.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,15 +62,6 @@ 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) { @@ -90,8 +83,26 @@ public Object call(Object obj, Method method, Object[] args, Callable origina WebElement realElement = locator.findElement(); if ("equals".equals(method.getName()) && args.length == 1) { - return areElementsEqual(realElement, args[0]); + return obj == args[0] || areElementsEqual(realElement, args[0]); } return getObject(realElement, method, args); } + + private static boolean areElementsEqual(@Nullable Object we1, @Nullable Object we2) { + if (we1 == we2) { + return true; + } + Object unwrapped1 = unwrapElement(we1); + Object unwrapped2 = unwrapElement(we2); + if (unwrapped1 == unwrapped2) { + return true; + } + if (!(unwrapped1 instanceof AppiumWebElement) && unwrapped2 instanceof AppiumWebElement) { + return unwrapped2.equals(unwrapped1); + } + if (!(unwrapped1 instanceof AppiumWebElement) || !(unwrapped2 instanceof AppiumWebElement)) { + return Objects.equals(unwrapped1, unwrapped2); + } + return Objects.equals(((AppiumWebElement) unwrapped1).getId(), ((AppiumWebElement) unwrapped2).getId()); + } } 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..3e8da60c4 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 @@ -22,10 +22,17 @@ import org.jspecify.annotations.Nullable; import org.openqa.selenium.SearchContext; 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 java.lang.reflect.Method; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; import static io.appium.java_client.pagefactory.bys.ContentType.HTML_OR_DEFAULT; @@ -36,6 +43,9 @@ * Utilities to unpack drivers and related objects from a {@link SearchContext}. */ public final class WebDriverUnpackUtility { + private static final String DECORATED_INTERFACE_NAME = + "org.openqa.selenium.support.decorators.Decorated"; + private WebDriverUnpackUtility() { } @@ -52,24 +62,29 @@ 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); - } + public static Optional unpackObjectFromSearchContext( + @Nullable SearchContext searchContext, Class cls + ) { + return unpackObjectFromSearchContext(searchContext, cls, ignored -> true); + } - return Optional.empty(); + /** + * Extracts an instance of {@code cls} matching {@code filter} from {@code searchContext}. + * + * @param the type of the object to extract + * @param searchContext the search context to inspect + * @param cls interface or class to extract + * @param filter predicate that the extracted instance must satisfy + * @return a matching instance or {@link Optional#empty()} + */ + public static Optional unpackObjectFromSearchContext( + @Nullable SearchContext searchContext, + Class cls, + Predicate filter + ) { + return doUnpackObjectFromSearchContext( + searchContext, cls, filter, Collections.newSetFromMap(new IdentityHashMap<>()) + ); } /** @@ -90,6 +105,34 @@ public static WebDriver unpackWebDriverFromSearchContext(@Nullable SearchContext return unpackObjectFromSearchContext(searchContext, WebDriver.class).orElse(null); } + /** + * Unwraps nested {@link WrapsElement} and Selenium decorator wrappers. + * + * @param element the object to unwrap + * @return the innermost unwrapped object, or {@code null} + */ + @Nullable + public static Object unwrapElement(@Nullable Object element) { + Object current = element; + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + while (current != null && visited.add(current)) { + if (current instanceof WrapsElement) { + WebElement wrapped = ((WrapsElement) current).getWrappedElement(); + if (wrapped != null && wrapped != current) { + current = wrapped; + continue; + } + } + Object decoratedOriginal = extractDecoratedOriginal(current); + if (decoratedOriginal instanceof WebElement && decoratedOriginal != current) { + current = decoratedOriginal; + continue; + } + break; + } + return current; + } + /** * Detects content type by the provided search {@code context}. * @@ -106,17 +149,157 @@ public static WebDriver unpackWebDriverFromSearchContext(@Nullable SearchContext * {@link WrapsDriver} */ public static ContentType getCurrentContentType(SearchContext context) { - var browserCheckHolder = unpackObjectFromSearchContext(context, HasBrowserCheck.class); - if (browserCheckHolder.filter(hbc -> !hbc.isBrowser()).isPresent()) { + var browserCheckHolder = unpackObjectFromSearchContext( + context, HasBrowserCheck.class, hbc -> !hbc.isBrowser() + ); + if (browserCheckHolder.isPresent()) { return NATIVE_MOBILE_SPECIFIC; } - var contextAware = unpackObjectFromSearchContext(context, SupportsContextSwitching.class); - if (contextAware.map(SupportsContextSwitching::getContext) - .filter(c -> c.toUpperCase(ROOT).contains(NATIVE_CONTEXT)).isPresent()) { + var contextAware = unpackObjectFromSearchContext( + context, + SupportsContextSwitching.class, + sc -> { + String currentContext = safeGetContext(sc); + return currentContext != null && currentContext.toUpperCase(ROOT).contains(NATIVE_CONTEXT); + } + ); + if (contextAware.isPresent()) { return NATIVE_MOBILE_SPECIFIC; } return HTML_OR_DEFAULT; } + + private static Optional doUnpackObjectFromSearchContext( + @Nullable SearchContext searchContext, + Class cls, + Predicate filter, + Set visited + ) { + if (searchContext == null || !visited.add(searchContext)) { + return Optional.empty(); + } + + Object decoratedOriginal = extractDecoratedOriginal(searchContext); + if (cls.isAssignableFrom(searchContext.getClass()) + && (decoratedOriginal == null || cls.isAssignableFrom(decoratedOriginal.getClass()))) { + T candidate = cls.cast(searchContext); + if (filter.test(candidate)) { + return Optional.of(candidate); + } + } + if (searchContext instanceof WrapsDriver) { + Optional unwrappedDriver = doUnpackObjectFromSearchContext( + ((WrapsDriver) searchContext).getWrappedDriver(), cls, filter, visited + ); + if (unwrappedDriver.isPresent()) { + return unwrappedDriver; + } + } + if (searchContext instanceof WrapsElement) { + Optional unwrappedElement = doUnpackObjectFromSearchContext( + ((WrapsElement) searchContext).getWrappedElement(), cls, filter, visited + ); + if (unwrappedElement.isPresent()) { + return unwrappedElement; + } + } + + if (decoratedOriginal instanceof SearchContext) { + Optional unwrappedOriginal = doUnpackObjectFromSearchContext( + (SearchContext) decoratedOriginal, cls, filter, visited + ); + if (unwrappedOriginal.isPresent()) { + return unwrappedOriginal; + } + } + + SearchContext decoratedDriver = extractDecoratedDriver(searchContext); + if (decoratedDriver != null) { + return doUnpackObjectFromSearchContext(decoratedDriver, cls, filter, visited); + } + + return Optional.empty(); + } + + @Nullable + private static String safeGetContext(SupportsContextSwitching contextAware) { + try { + return contextAware.getContext(); + } catch (WebDriverException e) { + return null; + } + } + + @Nullable + private static Object extractDecoratedOriginal(Object candidate) { + Class decoratedInterface = findDecoratedInterface(candidate.getClass()); + if (decoratedInterface == null) { + return null; + } + try { + Method getOriginal = decoratedInterface.getMethod("getOriginal"); + return getOriginal.invoke(candidate); + } catch (ReflectiveOperationException e) { + return null; + } + } + + @Nullable + private static SearchContext extractDecoratedDriver(Object candidate) { + Class decoratedInterface = findDecoratedInterface(candidate.getClass()); + if (decoratedInterface == null) { + return null; + } + try { + Method getDecorator = decoratedInterface.getMethod("getDecorator"); + Object decorator = getDecorator.invoke(candidate); + if (decorator == null) { + return null; + } + Method getDecoratedDriver = decorator.getClass().getMethod("getDecoratedDriver"); + 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 Class findDecoratedInterface(@Nullable Class type) { + for (Class current = type; current != null && !Object.class.equals(current); + current = current.getSuperclass()) { + for (Class iface : current.getInterfaces()) { + Class found = findDecoratedOnInterfaceHierarchy(iface); + if (found != null) { + return found; + } + } + } + return null; + } + + @Nullable + private static Class findDecoratedOnInterfaceHierarchy(Class iface) { + if (DECORATED_INTERFACE_NAME.equals(iface.getName())) { + return iface; + } + for (Class parent : iface.getInterfaces()) { + Class found = findDecoratedOnInterfaceHierarchy(parent); + if (found != null) { + return found; + } + } + 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..d120aea46 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,14 @@ 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 java.util.Objects; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.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 +246,20 @@ 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); + return Objects.equals(getId(), ((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..b3f229928 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.pagefactory.utils.WebDriverUnpackUtility.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..634ed8f09 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,76 @@ 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.pagefactory.AndroidFindBy; import io.appium.java_client.pagefactory.AppiumFieldDecorator; +import io.appium.java_client.pagefactory.Widget; +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.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.OutputType; +import org.openqa.selenium.Point; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.TakesScreenshot; 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.util.ArrayList; +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.assertSame; +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; + + @AndroidFindBy(id = "widget_root") + private SampleWidget widget; + + @AndroidFindBy(id = "widget_list") + private List widgets; + @Test public void canInvokeElementReturnedBySeleniumDecorator() { CountingDriver originalDriver = new CountingDriver(); @@ -47,16 +99,204 @@ 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); + assertNotEquals(appiumElement, originalDriver.secondaryElement); + assertNotEquals(originalDriver.secondaryElement, appiumElement); + } + + @Test + public void canUseAppiumWebElementAndWebElementListsWithSeleniumDecorator() { + 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); + } + + @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, WebDriverUnpackUtility.unwrapElement(cyclic)); + } + + 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 +305,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; From 8df1f0986248796082dfa9aaecf24b85b152091e Mon Sep 17 00:00:00 2001 From: Nacy Dev Date: Wed, 11 Nov 2026 09:12:27 +0700 Subject: [PATCH 2/3] fix: address review feedback on element equality, list adaptation, and unpacking --- .../java_client/internal/ElementHelpers.java | 212 ++++++++++++++++ .../pagefactory/AppiumElementLocator.java | 24 +- .../pagefactory/AppiumFieldDecorator.java | 73 +++++- .../pagefactory/ElementInterceptor.java | 69 +++-- .../pagefactory/ElementListInterceptor.java | 49 +++- .../SearchingWidgetRootHandler.java | 17 +- .../InterceptorOfASingleElement.java | 35 +-- .../utils/WebDriverUnpackUtility.java | 235 ++++++------------ .../java_client/remote/AppiumWebElement.java | 9 +- .../remote/WebElementToJsonConverter.java | 2 +- .../SeleniumDecoratorCompatibilityTest.java | 220 +++++++++++++++- 11 files changed, 667 insertions(+), 278 deletions(-) create mode 100644 src/main/java/io/appium/java_client/internal/ElementHelpers.java 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..42b8a01fe --- /dev/null +++ b/src/main/java/io/appium/java_client/internal/ElementHelpers.java @@ -0,0 +1,212 @@ +package io.appium.java_client.internal; + +import org.jspecify.annotations.Nullable; +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.WrapsElement; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** 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 ClassValue DECORATED_ACCESS = new ClassValue<>() { + @Override + protected DecoratedAccess computeValue(Class type) { + Class decoratedInterface = findDecoratedInterface(type); + if (decoratedInterface == null) { + return DecoratedAccess.NONE; + } + try { + Method getOriginal = decoratedInterface.getMethod("getOriginal"); + Method getDecorator = findMethodOrNull(decoratedInterface, "getDecorator"); + return new DecoratedAccess(getOriginal, getDecorator); + } catch (NoSuchMethodException e) { + return DecoratedAccess.NONE; + } + } + }; + + 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; + int depth = 0; + while (current != null) { + if (depth >= MAX_UNALLOCATED_DEPTH) { + if (visited == null) { + visited = Collections.newSetFromMap(new IdentityHashMap<>()); + recordPrefix(element, depth, visited); + } + if (!visited.add(current)) { + break; + } + } + Object next = unwrapSingleElementStep(current); + if (next == null || next == current) { + break; + } + current = next; + depth++; + } + return current; + } + + /** Extracts the immediate target wrapped by a Selenium decorator proxy. + * @param candidate the object to inspect + * @return the wrapped target, or null */ + @Nullable + public 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; + } + } + + /** Extracts the decorated driver search context from a Selenium decorator proxy. + * @param candidate the object to inspect + * @return the decorated driver search context, or null */ + @Nullable + public 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; + } + + private static void recordPrefix(@Nullable Object start, int steps, Set visited) { + Object cursor = start; + for (int i = 0; i < steps && cursor != null; i++) { + visited.add(cursor); + cursor = unwrapSingleElementStep(cursor); + } + } + + @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) { + try { + return owner.getMethod(name); + } catch (NoSuchMethodException e) { + return null; + } + } + + @Nullable + private static Class findDecoratedInterface(@Nullable Class type) { + for (Class current = type; current != null && !Object.class.equals(current); + current = current.getSuperclass()) { + for (Class iface : current.getInterfaces()) { + Class found = findDecoratedOnInterfaceHierarchy(iface); + if (found != null) { + return found; + } + } + } + return null; + } + + @Nullable + private static Class findDecoratedOnInterfaceHierarchy(Class iface) { + if (DECORATED_INTERFACE_NAME.equals(iface.getName())) { + return iface; + } + for (Class parent : iface.getInterfaces()) { + Class found = findDecoratedOnInterfaceHierarchy(parent); + 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 91164c413..9c5b83d09 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,7 +36,6 @@ 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; @@ -46,7 +44,7 @@ import static io.appium.java_client.pagefactory.ThrowableUtil.isInvalidSelectorRootCause; import static io.appium.java_client.pagefactory.ThrowableUtil.isStaleElementReferenceException; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.sameElement; import static java.lang.String.format; class AppiumElementLocator implements CacheableLocator { @@ -258,24 +256,6 @@ private static By[] alternativeLocators(By by) { return null; } - private static boolean sameRoot(WebElement left, WebElement right) { - if (left == right) { - return true; - } - Object unwrappedLeft = unwrapElement(left); - Object unwrappedRight = unwrapElement(right); - if (unwrappedLeft == unwrappedRight) { - return true; - } - if (unwrappedLeft instanceof AppiumWebElement && unwrappedRight instanceof AppiumWebElement) { - return Objects.equals( - ((AppiumWebElement) unwrappedLeft).getId(), - ((AppiumWebElement) unwrappedRight).getId() - ); - } - return Objects.equals(unwrappedLeft, unwrappedRight); - } - /** * Alternate locators captured for one content type. */ @@ -350,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 996e750f3..ea7327796 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -36,6 +36,8 @@ 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,9 +47,14 @@ import java.lang.reflect.Modifier; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import static io.appium.java_client.internal.ElementHelpers.extractDecoratedDriver; +import static io.appium.java_client.internal.ElementHelpers.extractDecoratedOriginal; 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,17 +233,52 @@ private static WeakReference requireWebDriverReference(SearchContext } @Nullable - private String readStringCapability(SearchContext searchContext, String capName) { - if (searchContext == null) { - return null; + private String readStringCapability(@Nullable SearchContext searchContext, String capName) { + SearchContext current = searchContext; + Set visited = null; + for (int depth = 0; current != null; depth++) { + if (depth >= 4) { + if (visited == null) { + visited = Collections.newSetFromMap(new IdentityHashMap<>()); + } + if (!visited.add(current)) { + return null; + } + } + var holder = unpackObjectFromSearchContext(current, HasCapabilities.class); + if (holder.isEmpty()) { + return null; + } + HasCapabilities capsHolder = holder.get(); + String value = extractStringCapability(capsHolder, capName); + if (value != null) { + return value; + } + current = stepSearchContext(capsHolder); } - return unpackObjectFromSearchContext( - searchContext, - HasCapabilities.class, - capsHolder -> extractStringCapability(capsHolder, capName) != null - ) - .map(capsHolder -> extractStringCapability(capsHolder, capName)) - .orElse(null); + return null; + } + + @Nullable + private static SearchContext stepSearchContext(Object candidate) { + if (candidate instanceof WrapsDriver) { + WebDriver wrapped = ((WrapsDriver) candidate).getWrappedDriver(); + if (wrapped != null && wrapped != candidate) { + return wrapped; + } + } + if (candidate instanceof WrapsElement) { + WebElement wrapped = ((WrapsElement) candidate).getWrappedElement(); + if (wrapped != null && wrapped != candidate) { + return wrapped; + } + } + Object decoratedOriginal = extractDecoratedOriginal(candidate); + if (decoratedOriginal instanceof SearchContext && decoratedOriginal != candidate) { + return (SearchContext) decoratedOriginal; + } + SearchContext decoratedDriver = extractDecoratedDriver(candidate); + return decoratedDriver != candidate ? decoratedDriver : null; } @Nullable @@ -274,7 +316,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); } @@ -308,7 +355,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 4b6b13ef6..5050c4163 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java @@ -25,11 +25,12 @@ import java.lang.ref.WeakReference; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.Arrays; 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.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; /** * Intercepts requests to {@link WebElement}. @@ -66,52 +67,46 @@ protected Object getObject(WebElement element, Method method, Object[] args) private static Object invokeElementMethod( WebElement element, Method method, Object[] args ) throws ReflectiveOperationException { - if (method.getDeclaringClass().isInstance(element)) { - return method.invoke(element, args); + 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 resolved.invoke(target, args); + } - Method interfaceOrObjectMethod = findInterfaceOrObjectMethod( - element.getClass(), method.getName(), method.getParameterTypes() + @Nullable + private static Method resolveTargetMethod(Class targetClass, Method method) { + if (method.getDeclaringClass().isAssignableFrom(targetClass) + && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + return method; + } + Method interfaceMethod = findPublicInterfaceMethod( + targetClass, method.getName(), method.getParameterTypes() ); - if (interfaceOrObjectMethod != null) { - return interfaceOrObjectMethod.invoke(element, args); + if (interfaceMethod != null) { + return interfaceMethod; } - - Object unwrapped = unwrapElement(element); - if (unwrapped != null && unwrapped != element) { - if (method.getDeclaringClass().isInstance(unwrapped)) { - return method.invoke(unwrapped, args); - } - Method unwrappedInterfaceMethod = findInterfaceOrObjectMethod( - unwrapped.getClass(), method.getName(), method.getParameterTypes() - ); - if (unwrappedInterfaceMethod != null) { - return unwrappedInterfaceMethod.invoke(unwrapped, args); - } - return unwrapped.getClass() - .getMethod(method.getName(), method.getParameterTypes()) - .invoke(unwrapped, args); + if (Modifier.isPublic(targetClass.getModifiers())) { + return findDeclaredPublicMethod(targetClass, method.getName(), method.getParameterTypes()); } - - return element.getClass() - .getMethod(method.getName(), method.getParameterTypes()) - .invoke(element, args); + return null; } @Nullable - private static Method findInterfaceOrObjectMethod( + private static Method findPublicInterfaceMethod( Class targetClass, String name, Class[] parameterTypes ) { - Method objectMethod = findDeclaredPublicMethod(Object.class, name, parameterTypes); - if (objectMethod != null) { - return objectMethod; - } - if (WebElement.class.isAssignableFrom(targetClass)) { - Method webElementMethod = findDeclaredPublicMethod(WebElement.class, name, parameterTypes); - if (webElementMethod != null) { - return webElementMethod; - } - } for (Class current = targetClass; current != null && !Object.class.equals(current); current = current.getSuperclass()) { for (Class iface : current.getInterfaces()) { 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 4b26367ca..dbae38332 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementListInterceptor.java @@ -19,12 +19,13 @@ 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.ArrayList; 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; @@ -32,6 +33,7 @@ * Intercepts requests to the list of {@link WebElement}. */ public class ElementListInterceptor extends InterceptorOfAListOfElements { + private final boolean adaptToAppiumElement; /** * Creates a new interceptor. @@ -39,22 +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 { - List adapted = new ArrayList<>(elements.size()); - for (WebElement element : elements) { - adapted.add(element == null || element instanceof AppiumWebElement - ? element - : getEnhancedProxy(AppiumWebElement.class, new ElementInterceptor(element))); + 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); } - return method.invoke(adapted, 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 7cdb4a9f8..ecbc24f2f 100644 --- a/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java +++ b/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java @@ -16,7 +16,6 @@ package io.appium.java_client.pagefactory; -import org.jspecify.annotations.Nullable; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebElement; @@ -34,8 +33,9 @@ import java.util.Set; import java.util.function.Supplier; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.sameElement; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; /** * Invocation handler for a {@link SearchingWidgetRoot} proxy. @@ -88,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); @@ -132,15 +132,6 @@ private List findElements(By by) { return found; } - private boolean same(Object proxy, @Nullable Object other) { - if (proxy == other || resolved == other) { - return true; - } - Object unwrappedResolved = unwrapElement(resolved); - Object unwrappedOther = unwrapElement(other); - return unwrappedResolved == unwrappedOther || Objects.equals(unwrappedResolved, unwrappedOther); - } - 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/interceptors/InterceptorOfASingleElement.java b/src/main/java/io/appium/java_client/pagefactory/interceptors/InterceptorOfASingleElement.java index eb554c1df..bd15ffed7 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,7 +28,8 @@ import java.util.Objects; import java.util.concurrent.Callable; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.sameElement; /** Base class of interceptors of method calls on a single element. */ public abstract class InterceptorOfASingleElement implements MethodCallListener { @@ -68,41 +68,30 @@ public Object call(Object obj, Method method, Object[] args, Callable origina 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 obj == args[0] || areElementsEqual(realElement, args[0]); - } - return getObject(realElement, method, args); - } - - private static boolean areElementsEqual(@Nullable Object we1, @Nullable Object we2) { - if (we1 == we2) { - return true; - } - Object unwrapped1 = unwrapElement(we1); - Object unwrapped2 = unwrapElement(we2); - if (unwrapped1 == unwrapped2) { - return true; - } - if (!(unwrapped1 instanceof AppiumWebElement) && unwrapped2 instanceof AppiumWebElement) { - return unwrapped2.equals(unwrapped1); + return sameElement(realElement, args[0]); } - if (!(unwrapped1 instanceof AppiumWebElement) || !(unwrapped2 instanceof AppiumWebElement)) { - return Objects.equals(unwrapped1, unwrapped2); + if ("hashCode".equals(method.getName()) && args.length == 0) { + return Objects.hashCode(unwrapElement(realElement)); } - return Objects.equals(((AppiumWebElement) unwrapped1).getId(), ((AppiumWebElement) unwrapped2).getId()); + 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 3e8da60c4..eeb78a11b 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,24 +17,27 @@ 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.proxy.HasMethodCallListeners; 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.WebDriverException; -import org.openqa.selenium.WebElement; import org.openqa.selenium.WrapsDriver; import org.openqa.selenium.WrapsElement; -import java.lang.reflect.Method; import java.util.Collections; import java.util.IdentityHashMap; +import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.function.Predicate; import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; +import static io.appium.java_client.internal.ElementHelpers.extractDecoratedDriver; +import static io.appium.java_client.internal.ElementHelpers.extractDecoratedOriginal; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; 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; @@ -43,8 +46,7 @@ * Utilities to unpack drivers and related objects from a {@link SearchContext}. */ public final class WebDriverUnpackUtility { - private static final String DECORATED_INTERFACE_NAME = - "org.openqa.selenium.support.decorators.Decorated"; + private static final int MAX_UNALLOCATED_DEPTH = 4; private WebDriverUnpackUtility() { } @@ -65,26 +67,7 @@ private WebDriverUnpackUtility() { public static Optional unpackObjectFromSearchContext( @Nullable SearchContext searchContext, Class cls ) { - return unpackObjectFromSearchContext(searchContext, cls, ignored -> true); - } - - /** - * Extracts an instance of {@code cls} matching {@code filter} from {@code searchContext}. - * - * @param the type of the object to extract - * @param searchContext the search context to inspect - * @param cls interface or class to extract - * @param filter predicate that the extracted instance must satisfy - * @return a matching instance or {@link Optional#empty()} - */ - public static Optional unpackObjectFromSearchContext( - @Nullable SearchContext searchContext, - Class cls, - Predicate filter - ) { - return doUnpackObjectFromSearchContext( - searchContext, cls, filter, Collections.newSetFromMap(new IdentityHashMap<>()) - ); + return doUnpackFromSearchContext(searchContext, cls, 0, null); } /** @@ -105,32 +88,31 @@ public static WebDriver unpackWebDriverFromSearchContext(@Nullable SearchContext return unpackObjectFromSearchContext(searchContext, WebDriver.class).orElse(null); } - /** - * Unwraps nested {@link WrapsElement} and Selenium decorator wrappers. - * - * @param element the object to unwrap - * @return the innermost unwrapped object, or {@code null} - */ - @Nullable - public static Object unwrapElement(@Nullable Object element) { - Object current = element; - Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); - while (current != null && visited.add(current)) { - if (current instanceof WrapsElement) { - WebElement wrapped = ((WrapsElement) current).getWrappedElement(); - if (wrapped != null && wrapped != current) { - current = wrapped; - continue; - } - } - Object decoratedOriginal = extractDecoratedOriginal(current); - if (decoratedOriginal instanceof WebElement && decoratedOriginal != current) { - current = decoratedOriginal; - continue; - } - break; + /** Checks whether two references resolve to the same unwrapped element. + * @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); } - return current; + if (!(unwrappedFirst instanceof HasMethodCallListeners) + && unwrappedSecond instanceof HasMethodCallListeners) { + return unwrappedSecond.equals(unwrappedFirst); + } + return Objects.equals(unwrappedFirst, unwrappedSecond); } /** @@ -149,66 +131,72 @@ public static Object unwrapElement(@Nullable Object element) { * {@link WrapsDriver} */ public static ContentType getCurrentContentType(SearchContext context) { - var browserCheckHolder = unpackObjectFromSearchContext( - context, HasBrowserCheck.class, hbc -> !hbc.isBrowser() - ); - if (browserCheckHolder.isPresent()) { + var browserCheckHolder = unpackObjectFromSearchContext(context, HasBrowserCheck.class); + if (browserCheckHolder.filter(hbc -> !hbc.isBrowser()).isPresent()) { return NATIVE_MOBILE_SPECIFIC; } - var contextAware = unpackObjectFromSearchContext( - context, - SupportsContextSwitching.class, - sc -> { - String currentContext = safeGetContext(sc); - return currentContext != null && currentContext.toUpperCase(ROOT).contains(NATIVE_CONTEXT); - } - ); - if (contextAware.isPresent()) { + var contextAware = unpackObjectFromSearchContext(context, SupportsContextSwitching.class); + if (contextAware.map(WebDriverUnpackUtility::safeGetContext) + .filter(c -> c.toUpperCase(ROOT).contains(NATIVE_CONTEXT)).isPresent()) { return NATIVE_MOBILE_SPECIFIC; } return HTML_OR_DEFAULT; } - private static Optional doUnpackObjectFromSearchContext( + private static Optional doUnpackFromSearchContext( @Nullable SearchContext searchContext, Class cls, - Predicate filter, - Set visited + int depth, + @Nullable Set visited ) { - if (searchContext == null || !visited.add(searchContext)) { + if (searchContext == null) { return Optional.empty(); } + Set activeVisited = visited; + if (depth >= MAX_UNALLOCATED_DEPTH) { + if (activeVisited == null) { + activeVisited = Collections.newSetFromMap(new IdentityHashMap<>()); + } + if (!activeVisited.add(searchContext)) { + return Optional.empty(); + } + } Object decoratedOriginal = extractDecoratedOriginal(searchContext); if (cls.isAssignableFrom(searchContext.getClass()) && (decoratedOriginal == null || cls.isAssignableFrom(decoratedOriginal.getClass()))) { - T candidate = cls.cast(searchContext); - if (filter.test(candidate)) { - return Optional.of(candidate); - } + return Optional.of(cls.cast(searchContext)); } + + int nextDepth = depth + 1; if (searchContext instanceof WrapsDriver) { - Optional unwrappedDriver = doUnpackObjectFromSearchContext( - ((WrapsDriver) searchContext).getWrappedDriver(), cls, filter, visited - ); - if (unwrappedDriver.isPresent()) { - return unwrappedDriver; + WebDriver wrappedDriver = ((WrapsDriver) searchContext).getWrappedDriver(); + if (wrappedDriver != searchContext) { + Optional unwrappedDriver = doUnpackFromSearchContext( + wrappedDriver, cls, nextDepth, activeVisited + ); + if (unwrappedDriver.isPresent()) { + return unwrappedDriver; + } } } if (searchContext instanceof WrapsElement) { - Optional unwrappedElement = doUnpackObjectFromSearchContext( - ((WrapsElement) searchContext).getWrappedElement(), cls, filter, visited - ); - if (unwrappedElement.isPresent()) { - return unwrappedElement; + var wrappedElement = ((WrapsElement) searchContext).getWrappedElement(); + if (wrappedElement != searchContext) { + Optional unwrappedElement = doUnpackFromSearchContext( + wrappedElement, cls, nextDepth, activeVisited + ); + if (unwrappedElement.isPresent()) { + return unwrappedElement; + } } } - if (decoratedOriginal instanceof SearchContext) { - Optional unwrappedOriginal = doUnpackObjectFromSearchContext( - (SearchContext) decoratedOriginal, cls, filter, visited + if (decoratedOriginal instanceof SearchContext && decoratedOriginal != searchContext) { + Optional unwrappedOriginal = doUnpackFromSearchContext( + (SearchContext) decoratedOriginal, cls, nextDepth, activeVisited ); if (unwrappedOriginal.isPresent()) { return unwrappedOriginal; @@ -216,8 +204,8 @@ private static Optional doUnpackObjectFromSearchContext( } SearchContext decoratedDriver = extractDecoratedDriver(searchContext); - if (decoratedDriver != null) { - return doUnpackObjectFromSearchContext(decoratedDriver, cls, filter, visited); + if (decoratedDriver != null && decoratedDriver != searchContext) { + return doUnpackFromSearchContext(decoratedDriver, cls, nextDepth, activeVisited); } return Optional.empty(); @@ -227,79 +215,8 @@ private static Optional doUnpackObjectFromSearchContext( private static String safeGetContext(SupportsContextSwitching contextAware) { try { return contextAware.getContext(); - } catch (WebDriverException e) { + } catch (UnsupportedCommandException | NoSuchContextException e) { return null; } } - - @Nullable - private static Object extractDecoratedOriginal(Object candidate) { - Class decoratedInterface = findDecoratedInterface(candidate.getClass()); - if (decoratedInterface == null) { - return null; - } - try { - Method getOriginal = decoratedInterface.getMethod("getOriginal"); - return getOriginal.invoke(candidate); - } catch (ReflectiveOperationException e) { - return null; - } - } - - @Nullable - private static SearchContext extractDecoratedDriver(Object candidate) { - Class decoratedInterface = findDecoratedInterface(candidate.getClass()); - if (decoratedInterface == null) { - return null; - } - try { - Method getDecorator = decoratedInterface.getMethod("getDecorator"); - Object decorator = getDecorator.invoke(candidate); - if (decorator == null) { - return null; - } - Method getDecoratedDriver = decorator.getClass().getMethod("getDecoratedDriver"); - 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 Class findDecoratedInterface(@Nullable Class type) { - for (Class current = type; current != null && !Object.class.equals(current); - current = current.getSuperclass()) { - for (Class iface : current.getInterfaces()) { - Class found = findDecoratedOnInterfaceHierarchy(iface); - if (found != null) { - return found; - } - } - } - return null; - } - - @Nullable - private static Class findDecoratedOnInterfaceHierarchy(Class iface) { - if (DECORATED_INTERFACE_NAME.equals(iface.getName())) { - return iface; - } - for (Class parent : iface.getInterfaces()) { - Class found = findDecoratedOnInterfaceHierarchy(parent); - if (found != null) { - return found; - } - } - 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 d120aea46..714bcccfc 100644 --- a/src/main/java/io/appium/java_client/remote/AppiumWebElement.java +++ b/src/main/java/io/appium/java_client/remote/AppiumWebElement.java @@ -35,7 +35,7 @@ import java.util.Map; import java.util.Objects; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; +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; @@ -259,7 +259,12 @@ public boolean equals(@Nullable Object obj) { if (!(other instanceof AppiumWebElement)) { return false; } - return Objects.equals(getId(), ((AppiumWebElement) other).getId()); + AppiumWebElement otherElement = (AppiumWebElement) other; + if (otherElement.id == null && otherElement.getClass() != AppiumWebElement.class + && getClass() == AppiumWebElement.class) { + return otherElement.equals(this); + } + return Objects.equals(getId(), otherElement.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 b3f229928..bf758641a 100644 --- a/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java +++ b/src/main/java/io/appium/java_client/remote/WebElementToJsonConverter.java @@ -26,7 +26,7 @@ import java.util.Map; import java.util.function.Function; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unwrapElement; +import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static java.util.stream.Collectors.toList; /** 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 634ed8f09..9b75913fb 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 @@ -20,14 +20,20 @@ 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; @@ -35,10 +41,13 @@ 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; @@ -51,6 +60,8 @@ import org.openqa.selenium.support.events.WebDriverListener; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -62,6 +73,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; 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 { @@ -80,6 +92,10 @@ public class SeleniumDecoratorCompatibilityTest { @AndroidFindBy(id = "list_id") private List webElements; + @CacheLookup + @AndroidFindBy(id = "list_id") + private List cachedAppiumElements; + @AndroidFindBy(id = "widget_root") private SampleWidget widget; @@ -152,12 +168,85 @@ public void canInvokeAppiumWebElementMethodsAndCompareEqualityWithDecoratedEleme 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); } @Test - public void canUseAppiumWebElementAndWebElementListsWithSeleniumDecorator() { + 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); @@ -175,6 +264,17 @@ public void canUseAppiumWebElementAndWebElementListsWithSeleniumDecorator() { 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 @@ -211,7 +311,7 @@ public void unpacksFromDualWrappersAndGuardsAgainstSelfReferentialCycles() { CyclicSearchContext cyclic = new CyclicSearchContext(); assertFalse(WebDriverUnpackUtility.unpackObjectFromSearchContext(cyclic, WebDriver.class).isPresent()); - assertSame(cyclic, WebDriverUnpackUtility.unwrapElement(cyclic)); + assertSame(cyclic, ElementHelpers.unwrapElement(cyclic)); } public static class SampleWidget extends Widget { @@ -520,4 +620,120 @@ 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, java.lang.reflect.Method method, Object[] args + ) throws Throwable { + return getObject(elements, method, args); + } + } } From 0ca393683f0a83cafb79338e5016da06579b7b3f Mon Sep 17 00:00:00 2001 From: Nacy Dev Date: Wed, 11 Nov 2026 15:31:27 +0700 Subject: [PATCH 3/3] refactor(pagefactory): consolidate element equality, method resolution, and wrapper walk in ElementHelpers --- .../java_client/internal/ElementHelpers.java | 201 ++++++++++++++---- .../pagefactory/AppiumElementLocator.java | 2 +- .../pagefactory/AppiumFieldDecorator.java | 60 +----- .../pagefactory/ElementInterceptor.java | 127 ++--------- .../SearchingWidgetRootHandler.java | 2 +- .../pagefactory/StaticElementLocator.java | 29 +++ .../InterceptorOfASingleElement.java | 2 +- .../utils/WebDriverUnpackUtility.java | 106 +-------- .../java_client/remote/AppiumWebElement.java | 9 +- .../SeleniumDecoratorCompatibilityTest.java | 78 ++++++- 10 files changed, 300 insertions(+), 316 deletions(-) create mode 100644 src/main/java/io/appium/java_client/pagefactory/StaticElementLocator.java diff --git a/src/main/java/io/appium/java_client/internal/ElementHelpers.java b/src/main/java/io/appium/java_client/internal/ElementHelpers.java index 42b8a01fe..dfe474153 100644 --- a/src/main/java/io/appium/java_client/internal/ElementHelpers.java +++ b/src/main/java/io/appium/java_client/internal/ElementHelpers.java @@ -1,35 +1,43 @@ 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 = findDecoratedInterface(type); + Class decoratedInterface = findInInterfaces( + type, iface -> DECORATED_INTERFACE_NAME.equals(iface.getName()) ? iface : null + ); if (decoratedInterface == null) { return DecoratedAccess.NONE; } - try { - Method getOriginal = decoratedInterface.getMethod("getOriginal"); - Method getDecorator = findMethodOrNull(decoratedInterface, "getDecorator"); - return new DecoratedAccess(getOriginal, getDecorator); - } catch (NoSuchMethodException e) { + Method getOriginal = findMethodOrNull(decoratedInterface, "getOriginal"); + if (getOriginal == null) { return DecoratedAccess.NONE; } + return new DecoratedAccess(getOriginal, findMethodOrNull(decoratedInterface, "getDecorator")); } }; @@ -50,32 +58,87 @@ private ElementHelpers() { public static Object unwrapElement(@Nullable Object element) { Object current = element; Set visited = null; - int depth = 0; - while (current != null) { - if (depth >= MAX_UNALLOCATED_DEPTH) { - if (visited == null) { - visited = Collections.newSetFromMap(new IdentityHashMap<>()); - recordPrefix(element, depth, visited); - } - if (!visited.add(current)) { - break; - } + 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; - depth++; } return current; } - /** Extracts the immediate target wrapped by a Selenium decorator proxy. - * @param candidate the object to inspect - * @return the wrapped target, or null */ + /** 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 - public static Object extractDecoratedOriginal(@Nullable Object candidate) { + private static Object extractDecoratedOriginal(@Nullable Object candidate) { if (candidate == null) { return null; } @@ -90,11 +153,8 @@ public static Object extractDecoratedOriginal(@Nullable Object candidate) { } } - /** Extracts the decorated driver search context from a Selenium decorator proxy. - * @param candidate the object to inspect - * @return the decorated driver search context, or null */ @Nullable - public static SearchContext extractDecoratedDriver(@Nullable Object candidate) { + private static SearchContext extractDecoratedDriver(@Nullable Object candidate) { if (candidate == null) { return null; } @@ -127,12 +187,76 @@ public static SearchContext extractDecoratedDriver(@Nullable Object candidate) { return null; } - private static void recordPrefix(@Nullable Object start, int steps, Set visited) { - Object cursor = start; - for (int i = 0; i < steps && cursor != null; i++) { - visited.add(cursor); - cursor = unwrapSingleElementStep(cursor); + @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 @@ -151,20 +275,20 @@ private static Object unwrapSingleElementStep(Object current) { } @Nullable - private static Method findMethodOrNull(Class owner, String name) { + private static Method findMethodOrNull(Class owner, String name, Class... parameterTypes) { try { - return owner.getMethod(name); + return owner.getMethod(name, parameterTypes); } catch (NoSuchMethodException e) { return null; } } @Nullable - private static Class findDecoratedInterface(@Nullable Class type) { + 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()) { - Class found = findDecoratedOnInterfaceHierarchy(iface); + R found = findOnInterfaceHierarchy(iface, mapper); if (found != null) { return found; } @@ -174,12 +298,13 @@ private static Class findDecoratedInterface(@Nullable Class type) { } @Nullable - private static Class findDecoratedOnInterfaceHierarchy(Class iface) { - if (DECORATED_INTERFACE_NAME.equals(iface.getName())) { - return iface; + 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()) { - Class found = findDecoratedOnInterfaceHierarchy(parent); + R found = findOnInterfaceHierarchy(parent, mapper); if (found != null) { return found; } 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 9c5b83d09..55d8c06c3 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumElementLocator.java @@ -40,11 +40,11 @@ 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; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.sameElement; import static java.lang.String.format; class AppiumElementLocator implements CacheableLocator { 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 ea7327796..67712d758 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -36,8 +36,6 @@ 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,14 +45,10 @@ import java.lang.reflect.Modifier; import java.time.Duration; import java.util.ArrayList; -import java.util.Collections; -import java.util.IdentityHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import static io.appium.java_client.internal.ElementHelpers.extractDecoratedDriver; -import static io.appium.java_client.internal.ElementHelpers.extractDecoratedOriginal; +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; @@ -233,52 +227,12 @@ private static WeakReference requireWebDriverReference(SearchContext } @Nullable - private String readStringCapability(@Nullable SearchContext searchContext, String capName) { - SearchContext current = searchContext; - Set visited = null; - for (int depth = 0; current != null; depth++) { - if (depth >= 4) { - if (visited == null) { - visited = Collections.newSetFromMap(new IdentityHashMap<>()); - } - if (!visited.add(current)) { - return null; - } - } - var holder = unpackObjectFromSearchContext(current, HasCapabilities.class); - if (holder.isEmpty()) { - return null; - } - HasCapabilities capsHolder = holder.get(); - String value = extractStringCapability(capsHolder, capName); - if (value != null) { - return value; - } - current = stepSearchContext(capsHolder); - } - return null; - } - - @Nullable - private static SearchContext stepSearchContext(Object candidate) { - if (candidate instanceof WrapsDriver) { - WebDriver wrapped = ((WrapsDriver) candidate).getWrappedDriver(); - if (wrapped != null && wrapped != candidate) { - return wrapped; - } - } - if (candidate instanceof WrapsElement) { - WebElement wrapped = ((WrapsElement) candidate).getWrappedElement(); - if (wrapped != null && wrapped != candidate) { - return wrapped; - } - } - Object decoratedOriginal = extractDecoratedOriginal(candidate); - if (decoratedOriginal instanceof SearchContext && decoratedOriginal != candidate) { - return (SearchContext) decoratedOriginal; - } - SearchContext decoratedDriver = extractDecoratedDriver(candidate); - return decoratedDriver != candidate ? decoratedDriver : null; + private static String readStringCapability(@Nullable SearchContext searchContext, String capName) { + return findInSearchContextChain( + searchContext, + HasCapabilities.class, + capsHolder -> extractStringCapability(capsHolder, capName) + ).orElse(null); } @Nullable 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 5050c4163..d5e8e8a6a 100644 --- a/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/ElementInterceptor.java @@ -18,16 +18,14 @@ import io.appium.java_client.pagefactory.interceptors.InterceptorOfASingleElement; import io.appium.java_client.support.pagefactory.ElementLocator; -import org.jspecify.annotations.Nullable; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import java.lang.ref.WeakReference; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.util.Arrays; -import java.util.List; +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; @@ -58,117 +56,24 @@ public ElementInterceptor(ElementLocator locator, WeakReference drive protected Object getObject(WebElement element, Method method, Object[] args) throws Throwable { try { - return invokeElementMethod(element, method, args); - } catch (Throwable t) { - throw extractReadableException(t); - } - } - - private static Object invokeElementMethod( - WebElement element, Method method, Object[] args - ) throws ReflectiveOperationException { - 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 resolved.invoke(target, args); - } - - @Nullable - private static Method resolveTargetMethod(Class targetClass, Method method) { - if (method.getDeclaringClass().isAssignableFrom(targetClass) - && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { - return method; - } - Method interfaceMethod = findPublicInterfaceMethod( - targetClass, method.getName(), method.getParameterTypes() - ); - if (interfaceMethod != null) { - return interfaceMethod; - } - if (Modifier.isPublic(targetClass.getModifiers())) { - return findDeclaredPublicMethod(targetClass, method.getName(), method.getParameterTypes()); - } - return null; - } - - @Nullable - private static Method findPublicInterfaceMethod( - Class targetClass, String name, Class[] parameterTypes - ) { - for (Class current = targetClass; current != null && !Object.class.equals(current); - current = current.getSuperclass()) { - for (Class iface : current.getInterfaces()) { - Method found = findOnInterfaceHierarchy(iface, name, parameterTypes); - if (found != null) { - return found; + 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); } } - } - return null; - } - - @Nullable - private static Method findOnInterfaceHierarchy( - Class iface, String name, Class[] parameterTypes - ) { - if (Modifier.isPublic(iface.getModifiers())) { - Method direct = findDeclaredPublicMethod(iface, name, parameterTypes); - if (direct != null) { - return direct; + if (resolved == null) { + throw new NoSuchMethodException( + target.getClass().getName() + "." + method.getName() + + Arrays.toString(method.getParameterTypes()) + ); } - } - for (Class parent : iface.getInterfaces()) { - Method found = findOnInterfaceHierarchy(parent, name, parameterTypes); - if (found != null) { - return found; - } - } - return null; - } - - @Nullable - private static Method findDeclaredPublicMethod( - Class owner, String name, Class[] parameterTypes - ) { - try { - return owner.getMethod(name, parameterTypes); - } catch (NoSuchMethodException e) { - return null; - } - } - - private static final class StaticElementLocator implements ElementLocator { - private final WebElement element; - - private 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(); + return resolved.invoke(target, args); + } catch (Throwable t) { + throw extractReadableException(t); } } } 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 ecbc24f2f..6a0f4b27a 100644 --- a/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java +++ b/src/main/java/io/appium/java_client/pagefactory/SearchingWidgetRootHandler.java @@ -33,8 +33,8 @@ 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.sameElement; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackWebDriverFromSearchContext; /** 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 bd15ffed7..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 @@ -28,8 +28,8 @@ 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; -import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.sameElement; /** Base class of interceptors of method calls on a single element. */ public abstract class InterceptorOfASingleElement implements MethodCallListener { 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 eeb78a11b..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 @@ -19,7 +19,6 @@ 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.proxy.HasMethodCallListeners; import io.appium.java_client.remote.SupportsContextSwitching; import org.jspecify.annotations.Nullable; import org.openqa.selenium.SearchContext; @@ -28,16 +27,11 @@ import org.openqa.selenium.WrapsDriver; import org.openqa.selenium.WrapsElement; -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; import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; -import static io.appium.java_client.internal.ElementHelpers.extractDecoratedDriver; -import static io.appium.java_client.internal.ElementHelpers.extractDecoratedOriginal; -import static io.appium.java_client.internal.ElementHelpers.unwrapElement; +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; @@ -46,7 +40,6 @@ * Utilities to unpack drivers and related objects from a {@link SearchContext}. */ public final class WebDriverUnpackUtility { - private static final int MAX_UNALLOCATED_DEPTH = 4; private WebDriverUnpackUtility() { } @@ -67,7 +60,7 @@ private WebDriverUnpackUtility() { public static Optional unpackObjectFromSearchContext( @Nullable SearchContext searchContext, Class cls ) { - return doUnpackFromSearchContext(searchContext, cls, 0, null); + return findInSearchContextChain(searchContext, cls, Function.identity()); } /** @@ -88,33 +81,6 @@ public static WebDriver unpackWebDriverFromSearchContext(@Nullable SearchContext return unpackObjectFromSearchContext(searchContext, WebDriver.class).orElse(null); } - /** Checks whether two references resolve to the same unwrapped element. - * @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); - } - /** * Detects content type by the provided search {@code context}. * @@ -145,72 +111,6 @@ public static ContentType getCurrentContentType(SearchContext context) { return HTML_OR_DEFAULT; } - private static Optional doUnpackFromSearchContext( - @Nullable SearchContext searchContext, - Class cls, - int depth, - @Nullable Set visited - ) { - if (searchContext == null) { - return Optional.empty(); - } - Set activeVisited = visited; - if (depth >= MAX_UNALLOCATED_DEPTH) { - if (activeVisited == null) { - activeVisited = Collections.newSetFromMap(new IdentityHashMap<>()); - } - if (!activeVisited.add(searchContext)) { - return Optional.empty(); - } - } - - Object decoratedOriginal = extractDecoratedOriginal(searchContext); - if (cls.isAssignableFrom(searchContext.getClass()) - && (decoratedOriginal == null || cls.isAssignableFrom(decoratedOriginal.getClass()))) { - return Optional.of(cls.cast(searchContext)); - } - - int nextDepth = depth + 1; - if (searchContext instanceof WrapsDriver) { - WebDriver wrappedDriver = ((WrapsDriver) searchContext).getWrappedDriver(); - if (wrappedDriver != searchContext) { - Optional unwrappedDriver = doUnpackFromSearchContext( - wrappedDriver, cls, nextDepth, activeVisited - ); - if (unwrappedDriver.isPresent()) { - return unwrappedDriver; - } - } - } - if (searchContext instanceof WrapsElement) { - var wrappedElement = ((WrapsElement) searchContext).getWrappedElement(); - if (wrappedElement != searchContext) { - Optional unwrappedElement = doUnpackFromSearchContext( - wrappedElement, cls, nextDepth, activeVisited - ); - if (unwrappedElement.isPresent()) { - return unwrappedElement; - } - } - } - - if (decoratedOriginal instanceof SearchContext && decoratedOriginal != searchContext) { - Optional unwrappedOriginal = doUnpackFromSearchContext( - (SearchContext) decoratedOriginal, cls, nextDepth, activeVisited - ); - if (unwrappedOriginal.isPresent()) { - return unwrappedOriginal; - } - } - - SearchContext decoratedDriver = extractDecoratedDriver(searchContext); - if (decoratedDriver != null && decoratedDriver != searchContext) { - return doUnpackFromSearchContext(decoratedDriver, cls, nextDepth, activeVisited); - } - - return Optional.empty(); - } - @Nullable private static String safeGetContext(SupportsContextSwitching contextAware) { try { 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 714bcccfc..b57898fa7 100644 --- a/src/main/java/io/appium/java_client/remote/AppiumWebElement.java +++ b/src/main/java/io/appium/java_client/remote/AppiumWebElement.java @@ -33,7 +33,6 @@ import java.util.List; import java.util.Map; -import java.util.Objects; import static io.appium.java_client.internal.ElementHelpers.unwrapElement; import static io.appium.java_client.remote.DriverCommand.FIND_CHILD_ELEMENT; @@ -259,12 +258,8 @@ public boolean equals(@Nullable Object obj) { if (!(other instanceof AppiumWebElement)) { return false; } - AppiumWebElement otherElement = (AppiumWebElement) other; - if (otherElement.id == null && otherElement.getClass() != AppiumWebElement.class - && getClass() == AppiumWebElement.class) { - return otherElement.equals(this); - } - return Objects.equals(getId(), otherElement.getId()); + String thisId = getId(); + return thisId != null && thisId.equals(((AppiumWebElement) other).getId()); } @Override 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 9b75913fb..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 @@ -59,6 +59,7 @@ 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; @@ -72,6 +73,8 @@ 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; @@ -185,6 +188,14 @@ public void canInvokeAppiumWebElementMethodsAndCompareEqualityWithDecoratedEleme 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 @@ -314,6 +325,44 @@ public void unpacksFromDualWrappersAndGuardsAgainstSelfReferentialCycles() { 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; @@ -731,9 +780,36 @@ public ExposedElementListInterceptor() { } public Object invokeListMethod( - List elements, java.lang.reflect.Method method, Object[] args + 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; + } + } }