diff --git a/docs/Page-objects.md b/docs/Page-objects.md index c1bc445bc..0ca7ea83c 100644 --- a/docs/Page-objects.md +++ b/docs/Page-objects.md @@ -684,7 +684,7 @@ and then This use case has some restrictions; -- All classes which are declared by the OverrideWidget annotation should be subclasses of the class declared by field +- All classes which are declared by the OverrideWidget annotation should be subclasses of the class declared by the field, or implementations of the interface declared by the field - All classes which are declared by the OverrideWidget should not be abstract. If a declared class is overridden partially like @@ -700,6 +700,20 @@ This use case has some restrictions; //below is the other field/method declaration ``` +The field may be an interface implemented by those classes. Each class named by `@OverrideWidget` must extend `Widget` and implement the interface. The interface must be public. Creation succeeds when this library's class loader can see that interface, or when the interface's class loader can see this library. A package-private or protected interface, and loaders that cannot see either way, fail with `IllegalArgumentException` instead of a linkage error. A content type that is left as the default cannot be instantiated, so name a class for every content type the field is used with: + +```java +public interface UsersWidget { + void doWork(); +} + +@FindBy(id = "users") +@OverrideWidget(html = HtmlUsersWidget.class, + androidUIAutomator = AndroidUsersWidget.class, + iOSXCUITAutomation = IosUsersWidget.class) +UsersWidget widget; +``` + - for now it is not possible to ```java 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..230a02cac 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -49,6 +49,7 @@ import java.util.Map; import static io.appium.java_client.internal.Preconditions.checkNotNull; +import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedInterfaceProxy; import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedProxy; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.unpackObjectFromSearchContext; import static io.appium.java_client.remote.options.SupportsAutomationNameOption.AUTOMATION_NAME_OPTION; @@ -335,6 +336,43 @@ private static Class resolveWidgetClass( return null; } + /** + * Widget class for a field, including an {@code @OverrideWidget} interface. + * + * @param field the page object field + * @return the widget class or interface, or null + */ + @Nullable + static Class declaredWidgetType(Field field) { + Class pageClass = PageFactory.getCurrentPageClass(); + return declaredWidgetType( + field, + ElementFieldDecorator.resolveSingleFieldClasses(field, pageClass), + ElementFieldDecorator.resolveListClasses(field, pageClass) + ); + } + + @Nullable + private static Class declaredWidgetType( + Field field, + List> fieldClasses, + List> listElementClasses + ) { + Class widgetClass = resolveWidgetClass(field, fieldClasses, listElementClasses); + if (widgetClass != null) { + return widgetClass; + } + List> candidates = List.class.isAssignableFrom(field.getType()) + ? listElementClasses + : fieldClasses; + for (Class candidate : candidates) { + if (isOverriddenWidgetInterface(candidate, field)) { + return candidate; + } + } + return null; + } + /** * Decorated page object {@code field}. * @@ -349,7 +387,7 @@ public Object decorate(ClassLoader ignored, Field field) { Class pageClass = PageFactory.getCurrentPageClass(); List> fieldClasses = ElementFieldDecorator.resolveSingleFieldClasses(field, pageClass); List> listElementClasses = ElementFieldDecorator.resolveListClasses(field, pageClass); - Class widgetClass = resolveWidgetClass(field, fieldClasses, listElementClasses); + Class widgetClass = declaredWidgetType(field, fieldClasses, listElementClasses); Object result = decorateWidget(field, widgetClass, pageClass); if (result == null) { result = decorateBy(field, pageClass); @@ -445,17 +483,21 @@ private static By unwrapSingleBy(@Nullable By by) { } @Nullable + @SuppressWarnings("unchecked") private Object decorateWidget( Field field, - @Nullable Class widgetType, + @Nullable Class widgetType, @Nullable Class pageClass ) { if (widgetType == null) { return null; } boolean isAlist = List.class.isAssignableFrom(field.getType()); + Class locatorType = widgetType.isInterface() + ? null + : (Class) widgetType; - CacheableLocator locator = widgetLocatorFactory.createLocator(field, pageClass, widgetType); + CacheableLocator locator = widgetLocatorFactory.createLocator(field, pageClass, locatorType); Map> map = OverrideWidgetReader.read(widgetType, field, platform); if (isAlist) { @@ -472,22 +514,43 @@ private Object decorateWidget( ); } - Constructor constructor = WidgetConstructorUtil.findConvenientConstructor(widgetType); + WidgetInterceptor interceptor = new WidgetInterceptor( + locator, + webDriverReference, + null, + map, + duration, + defaultLocatorGroupStrategy + ); + if (widgetType.isInterface()) { + return getEnhancedInterfaceProxy(widgetType, interceptor); + } + + //noinspection unchecked + Class widgetClass = (Class) widgetType; + Constructor constructor = WidgetConstructorUtil.findConvenientConstructor(widgetClass); return getEnhancedProxy( - widgetType, + widgetClass, new Class[]{constructor.getParameterTypes()[0]}, new Object[]{proxyForAnElement(locator, webDriverReference)}, - new WidgetInterceptor( - locator, - webDriverReference, - null, - map, - duration, - defaultLocatorGroupStrategy - ) + interceptor ); } + /** + * True when {@code type} is a widget contract rather than a {@link WebElement}. + * {@code @OverrideWidget} on an element field keeps the normal element proxy. + * + * @param type the field type, or the list element type + * @param field the annotated field + * @return whether the type should be decorated as an interface widget + */ + static boolean isOverriddenWidgetInterface(Class type, Field field) { + return type.isInterface() + && !WebElement.class.isAssignableFrom(type) + && field.isAnnotationPresent(OverrideWidget.class); + } + private static WebElement proxyForAnElement( ElementLocator locator, WeakReference webDriverReference diff --git a/src/main/java/io/appium/java_client/pagefactory/OverrideWidget.java b/src/main/java/io/appium/java_client/pagefactory/OverrideWidget.java index 53bdee8f0..3d01f6900 100644 --- a/src/main/java/io/appium/java_client/pagefactory/OverrideWidget.java +++ b/src/main/java/io/appium/java_client/pagefactory/OverrideWidget.java @@ -23,7 +23,11 @@ /** * This annotation should mark an abstract/certain Widget object class - * or field that declares some abstract/certain Widget object class. + * or field that declares some abstract/certain Widget object class or a public interface + * implemented by those classes. A package-private or protected interface is rejected with + * {@link IllegalArgumentException}. A public interface is accepted when this library's + * class loader can see it, or when its class loader can see this library. Otherwise + * initialization throws {@link IllegalArgumentException}. * io.appium.java_client.pagefactory.Widget is the Appium-specific extension of the * Page Object design pattern. * About the Page Object design pattern please read these documents: @@ -33,8 +37,8 @@ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD}) public @interface OverrideWidget { /** * This property is designed for HTML (browser or web view) content. - * A declared class should not be abstract. Declared class also should be a subclass - * of an annotated class/class which is declared by an annotated field. + * A declared class should not be abstract. It must be a subclass of the field type, + * or an implementation of the field type when that type is an interface. * * @return a class which extends {@link io.appium.java_client.pagefactory.Widget} */ @@ -42,8 +46,8 @@ /** * This property is designed for Android native content. - * A declared class should not be abstract. Declared class also should be a subclass - * of an annotated class/class which is declared by an annotated field. + * A declared class should not be abstract. It must be a subclass of the field type, + * or an implementation of the field type when that type is an interface. * * @return a class which extends {@link io.appium.java_client.pagefactory.Widget} */ @@ -52,8 +56,8 @@ /** * This property is designed for iOS native content when * {@link io.appium.java_client.remote.AutomationName#IOS_XCUI_TEST} automation is used. - * A declared class should not be abstract. Declared class also should be a subclass - * of an annotated class/class which is declared by an annotated field. + * A declared class should not be abstract. It must be a subclass of the field type, + * or an implementation of the field type when that type is an interface. * * @return a class which extends {@link io.appium.java_client.pagefactory.Widget} */ @@ -61,8 +65,8 @@ /** * This property is designed for Windows native content. - * A declared class should not be abstract. Declared class also should be a subclass - * of an annotated class/class which is declared by an annotated field. + * A declared class should not be abstract. It must be a subclass of the field type, + * or an implementation of the field type when that type is an interface. * * @return a class which extends {@link io.appium.java_client.pagefactory.Widget} */ diff --git a/src/main/java/io/appium/java_client/pagefactory/OverrideWidgetReader.java b/src/main/java/io/appium/java_client/pagefactory/OverrideWidgetReader.java index 09984729c..79e6cb28e 100644 --- a/src/main/java/io/appium/java_client/pagefactory/OverrideWidgetReader.java +++ b/src/main/java/io/appium/java_client/pagefactory/OverrideWidgetReader.java @@ -40,15 +40,14 @@ class OverrideWidgetReader { private OverrideWidgetReader() { } - @SuppressWarnings("unchecked") - private static Class getConvenientClass(Class declaredClass, - AnnotatedElement annotatedElement, String method) { - Class convenientClass; + private static Class getConvenientClass(Class declaredClass, + AnnotatedElement annotatedElement, String method) { + Class convenientClass; OverrideWidget overrideWidget = annotatedElement.getAnnotation(OverrideWidget.class); try { if (overrideWidget == null || (convenientClass = - (Class) OverrideWidget.class + (Class) OverrideWidget.class .getDeclaredMethod(method).invoke(overrideWidget)) .equals(EMPTY)) { convenientClass = declaredClass; @@ -68,13 +67,12 @@ private static Class getConvenientClass(Class getDefaultOrHTMLWidgetClass( - Class declaredClass, AnnotatedElement annotatedElement) { + static Class getDefaultOrHTMLWidgetClass(Class declaredClass, AnnotatedElement annotatedElement) { return getConvenientClass(declaredClass, annotatedElement, HTML); } - static Class getMobileNativeWidgetClass(Class declaredClass, - AnnotatedElement annotatedElement, String platform) { + static Class getMobileNativeWidgetClass(Class declaredClass, + AnnotatedElement annotatedElement, String platform) { String transformedPlatform = String.valueOf(platform).toUpperCase(ROOT).trim(); if (ANDROID.equalsIgnoreCase(transformedPlatform)) { @@ -92,22 +90,30 @@ static Class getMobileNativeWidgetClass(Class findConstructor(Class clazz) { + if (clazz == null || clazz.isInterface() || !Widget.class.isAssignableFrom(clazz)) { + return null; + } + return findConvenientConstructor((Class) clazz); + } + private static Constructor getConstructorOfADefaultOrHTMLWidget( - Class declaredClass, AnnotatedElement annotatedElement) { - Class clazz = - getDefaultOrHTMLWidgetClass(declaredClass, annotatedElement); - return findConvenientConstructor(clazz); + Class declaredClass, AnnotatedElement annotatedElement) { + return findConstructor(getDefaultOrHTMLWidgetClass(declaredClass, annotatedElement)); } private static Constructor getConstructorOfAMobileNativeWidgets( - Class declaredClass, AnnotatedElement annotatedElement, String platform) { - Class clazz = - getMobileNativeWidgetClass(declaredClass, annotatedElement, platform); - return findConvenientConstructor(clazz); + Class declaredClass, AnnotatedElement annotatedElement, String platform) { + return findConstructor(getMobileNativeWidgetClass(declaredClass, annotatedElement, platform)); } protected static Map> read( - Class declaredClass, AnnotatedElement annotatedElement, String platform) { + Class declaredClass, AnnotatedElement annotatedElement, String platform) { Map> result = new HashMap<>(); result.put(ContentType.HTML_OR_DEFAULT, getConstructorOfADefaultOrHTMLWidget(declaredClass, annotatedElement)); diff --git a/src/main/java/io/appium/java_client/pagefactory/WidgetByBuilder.java b/src/main/java/io/appium/java_client/pagefactory/WidgetByBuilder.java index 77a2d631d..7d819a956 100644 --- a/src/main/java/io/appium/java_client/pagefactory/WidgetByBuilder.java +++ b/src/main/java/io/appium/java_client/pagefactory/WidgetByBuilder.java @@ -59,7 +59,10 @@ private By getByFromDeclaredClass(WhatIsNeeded whatIsNeeded) { By result = null; try { - Class declaredClass = resolveWidgetClassForField(field); + Class declaredClass = resolveWidgetClassForField(field); + if (declaredClass == null) { + declaredClass = AppiumFieldDecorator.declaredWidgetType(field); + } if (declaredClass == null) { return null; } @@ -67,15 +70,12 @@ private By getByFromDeclaredClass(WhatIsNeeded whatIsNeeded) { Class convenientClass; if (whatIsNeeded.equals(WhatIsNeeded.DEFAULT_OR_HTML)) { - convenientClass = - getDefaultOrHTMLWidgetClass(declaredClass, field); + convenientClass = getDefaultOrHTMLWidgetClass(declaredClass, field); } else { - convenientClass = - getMobileNativeWidgetClass(declaredClass, field, - platform); + convenientClass = getMobileNativeWidgetClass(declaredClass, field, platform); } - while (result == null && !convenientClass.equals(Object.class)) { + while (result == null && convenientClass != null && !convenientClass.equals(Object.class)) { setAnnotated(convenientClass); if (whatIsNeeded.equals(WhatIsNeeded.DEFAULT_OR_HTML)) { result = super.buildDefaultBy(); diff --git a/src/main/java/io/appium/java_client/pagefactory/WidgetInterceptor.java b/src/main/java/io/appium/java_client/pagefactory/WidgetInterceptor.java index e4e6fb6b9..c890cee15 100644 --- a/src/main/java/io/appium/java_client/pagefactory/WidgetInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/WidgetInterceptor.java @@ -36,7 +36,6 @@ import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; -import static java.util.Optional.ofNullable; /** * Proxy interceptor class for widgets. @@ -59,6 +58,8 @@ public class WidgetInterceptor extends InterceptorOfASingleElement { private final Duration duration; private final LocatorGroupStrategy defaultLocatorGroupStrategy; private WeakReference cachedElementReference; + // Interface list proxies do not store the element. Hold it until the widget, which keeps it, exists. + private WebElement elementUntilWidgetCreated; /** * Creates a new interceptor. @@ -96,6 +97,7 @@ public WidgetInterceptor( ) { super(locator, driverReference); this.cachedElementReference = cachedElementReference; + this.elementUntilWidgetCreated = cachedElementReference == null ? null : cachedElementReference.get(); this.instantiationMap = instantiationMap; this.duration = duration; this.defaultLocatorGroupStrategy = defaultLocatorGroupStrategy; @@ -120,7 +122,14 @@ private boolean isCached(ContentType type) { } private void rejectAbstractWidget(ContentType type) throws InstantiationException { - Class clazz = instantiationMap.get(type).getDeclaringClass(); + Constructor constructor = instantiationMap.get(type); + if (constructor == null) { + throw new InstantiationException(String.format( + "No concrete @OverrideWidget class is declared for %s. " + + "A field typed as an interface needs a widget class for this content.", + type)); + } + Class clazz = constructor.getDeclaringClass(); if (Modifier.isAbstract(clazz.getModifiers())) { throw new InstantiationException( String.format("%s is abstract so it cannot be instantiated", clazz.getName()) @@ -142,6 +151,7 @@ private void bind(ContentType type, WebElement root) throws Throwable { ); cachedElementReference = new WeakReference<>(root); cachedInstances.put(type, widget); + elementUntilWidgetCreated = null; } private static Object invoke(Widget widget, Method method, Object[] args) throws Throwable { @@ -155,9 +165,14 @@ private static Object invoke(Widget widget, Method method, Object[] args) throws @Override public Object call(Object obj, Method method, Object[] args, Callable original) throws Throwable { - WebElement element = ofNullable(cachedElementReference).map(WeakReference::get).orElse(null); + WebElement element = referencedElement(); return locator == null && element != null ? getObject(element, method, args) : super.call(obj, method, args, original); } + + private WebElement referencedElement() { + WebElement cached = cachedElementReference == null ? null : cachedElementReference.get(); + return cached != null ? cached : elementUntilWidgetCreated; + } } diff --git a/src/main/java/io/appium/java_client/pagefactory/WidgetListInterceptor.java b/src/main/java/io/appium/java_client/pagefactory/WidgetListInterceptor.java index cdaca4721..4a1cfddf4 100644 --- a/src/main/java/io/appium/java_client/pagefactory/WidgetListInterceptor.java +++ b/src/main/java/io/appium/java_client/pagefactory/WidgetListInterceptor.java @@ -34,6 +34,7 @@ import java.util.stream.Collectors; import static io.appium.java_client.pagefactory.ThrowableUtil.extractReadableException; +import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedInterfaceProxy; import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedProxy; import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; import static java.util.Optional.ofNullable; @@ -41,8 +42,8 @@ /** Proxy interceptor class for lists of widgets. */ public class WidgetListInterceptor extends InterceptorOfAListOfElements { private final Map> instantiationMap; - private final List cachedWidgets = new ArrayList<>(); - private final Class declaredType; + private final List cachedWidgets = new ArrayList<>(); + private final Class declaredType; private final Duration duration; private final LocatorGroupStrategy defaultLocatorGroupStrategy; private final WeakReference driver; @@ -54,14 +55,14 @@ public class WidgetListInterceptor extends InterceptorOfAListOfElements { * @param locator the locator of the widget elements * @param driver the reference to the driver * @param instantiationMap the widget constructors mapped by the content type - * @param declaredType the declared widget type + * @param declaredType the declared widget class, or an interface implemented by the override classes * @param duration the timeout of waiting for an element presence */ public WidgetListInterceptor( @Nullable CacheableLocator locator, WeakReference driver, Map> instantiationMap, - Class declaredType, + Class declaredType, Duration duration ) { this( @@ -78,7 +79,7 @@ public WidgetListInterceptor( @Nullable CacheableLocator locator, WeakReference driver, Map> instantiationMap, - Class declaredType, + Class declaredType, Duration duration, LocatorGroupStrategy defaultLocatorGroupStrategy ) { @@ -104,21 +105,21 @@ protected Object getObject(List elements, Method method, Object[] ar ContentType type = null; for (WebElement element : elements) { type = ofNullable(type).orElseGet(() -> getCurrentContentType(element)); - Class[] params = new Class[] {instantiationMap.get(type).getParameterTypes()[0]}; WeakReference elementReference = new WeakReference<>(element); - cachedWidgets.add( - getEnhancedProxy( - declaredType, params, new Object[] {element}, - new WidgetInterceptor( - null, - driver, - elementReference, - instantiationMap, - duration, - defaultLocatorGroupStrategy - ) - ) + WidgetInterceptor interceptor = new WidgetInterceptor( + null, + driver, + elementReference, + instantiationMap, + duration, + defaultLocatorGroupStrategy ); + if (declaredType.isInterface()) { + cachedWidgets.add(getEnhancedInterfaceProxy(declaredType, interceptor)); + } else { + Class[] params = new Class[] {instantiationMap.get(type).getParameterTypes()[0]}; + cachedWidgets.add(proxyWidget(declaredType, params, element, interceptor)); + } cachedElementReferences.add(elementReference); } } @@ -128,4 +129,14 @@ protected Object getObject(List elements, Method method, Object[] ar throw extractReadableException(t); } } + + @SuppressWarnings("unchecked") + private static Widget proxyWidget( + Class declaredType, + Class[] params, + WebElement element, + WidgetInterceptor interceptor + ) { + return getEnhancedProxy((Class) declaredType, params, new Object[] {element}, interceptor); + } } diff --git a/src/main/java/io/appium/java_client/pagefactory/utils/ProxyFactory.java b/src/main/java/io/appium/java_client/pagefactory/utils/ProxyFactory.java index b0ee40c7e..8507416df 100644 --- a/src/main/java/io/appium/java_client/pagefactory/utils/ProxyFactory.java +++ b/src/main/java/io/appium/java_client/pagefactory/utils/ProxyFactory.java @@ -39,6 +39,9 @@ public final class ProxyFactory { private static final Set NON_PROXYABLE_METHODS = setWithout( OBJECT_METHOD_NAMES, "toString", "equals", "hashCode" ); + private static final ElementMatcher INTERFACE_METHODS = not( + namedOneOf(NON_PROXYABLE_METHODS.toArray(new String[0])) + ); @SafeVarargs private static Set setWithout(@SuppressWarnings("SameParameterValue") Set source, T... items) { @@ -97,4 +100,28 @@ public static T getEnhancedProxy( extraMatcher ); } + + /** + * Creates a proxy that implements {@code iface} and forwards its public methods to {@code listener}. + * The interface must be public. Creation succeeds when this library's class loader can see + * {@code iface}, or when {@code iface}'s class loader can see this library. + * A package-private or protected interface, or loaders that cannot see either way, + * is rejected with {@link IllegalArgumentException}. + * + * @param iface an interface type + * @param listener receives the proxied calls + * @return a proxy that implements {@code iface} + */ + public static Object getEnhancedInterfaceProxy(Class iface, MethodCallListener listener) { + if (iface == null || !iface.isInterface()) { + throw new IllegalArgumentException("Expected an interface, got " + iface); + } + if (listener == null) { + throw new IllegalArgumentException("Listener must not be null"); + } + @SuppressWarnings("unchecked") + Class type = (Class) iface; + return createProxy(type, new Object[] {}, new Class[] {}, Collections.singletonList(listener), + INTERFACE_METHODS); + } } diff --git a/src/main/java/io/appium/java_client/proxy/Helpers.java b/src/main/java/io/appium/java_client/proxy/Helpers.java index 8ee21706d..bcf38c357 100644 --- a/src/main/java/io/appium/java_client/proxy/Helpers.java +++ b/src/main/java/io/appium/java_client/proxy/Helpers.java @@ -29,6 +29,7 @@ import org.jspecify.annotations.Nullable; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.Collection; import java.util.Collections; import java.util.Map; @@ -42,6 +43,11 @@ /** * Helpers for creating transparent proxies of classes. + * + *

A public interface is proxied when this library's class loader can see the interface, + * or when the interface's class loader can see this library. + * A package-private or protected interface, or a public interface for which neither is true, + * is rejected with {@link IllegalArgumentException}. */ public class Helpers { /** @@ -56,6 +62,8 @@ public class Helpers { // the performance and to avoid extensive memory usage for our case, where // the amount of instrumented proxy classes we create is low in comparison to the amount // of proxy instances. + // The key is a signature object. The generated class does not reference that object, + // so collecting the weak key does not depend on the interface or widget class. private static final Map> CACHED_PROXY_CLASSES = Collections.synchronizedMap(new WeakHashMap<>()); @@ -71,10 +79,14 @@ private Helpers() { * are expected to be skipped if they throw * {@link io.appium.java_client.proxy.NotImplementedException}. * - * @param cls the class to which the proxy should be created. - * Must not be an interface. + * @param cls the class to proxy, or a public interface. + * A non-public interface is rejected with + * {@link IllegalArgumentException}. A public interface is also + * rejected unless this library's loader can see it, or its loader + * can see this library. * @param constructorArgs Array of constructor arguments. Could be an * empty array if the class provides a constructor without arguments. + * Must be empty when {@code cls} is an interface. * @param constructorArgTypes Array of constructor argument types. Must * represent types of constructorArgs. * @param listeners One or more method invocation listeners. @@ -103,8 +115,11 @@ public static T createProxy( * {@link io.appium.java_client.proxy.NotImplementedException}. * !!! This API is designed for private usage. * - * @param cls The class to which the proxy should be created. - * Must not be an interface. + * @param cls The class to proxy, or a public interface. + * A non-public interface is rejected with + * {@link IllegalArgumentException}. A public interface is also + * rejected unless this library's loader can see it, or its loader + * can see this library. * @param constructorArgs Array of constructor arguments. Could be an * empty array if the class provides a constructor without arguments. * @param constructorArgTypes Array of constructor argument types. Must @@ -121,6 +136,16 @@ public static T createProxy( Collection listeners, @Nullable ElementMatcher extraMethodMatcher ) { + requireNonNull(cls, "Class must not be null"); + if (cls.isInterface()) { + Preconditions.checkArgument(Modifier.isPublic(cls.getModifiers()), String.format( + "Cannot proxy non-public interface %s. The generated proxy is defined in its own class loader, " + + "so it cannot implement a package-private or protected interface. Declare %s public.", + cls.getName(), cls.getSimpleName() + )); + Preconditions.checkArgument(constructorArgs.length == 0 && constructorArgTypes.length == 0, + cls.getName() + " is an interface, so the proxy has no constructor arguments"); + } var signature = ProxyClassSignature.of(cls, constructorArgTypes, extraMethodMatcher); var proxyClass = CACHED_PROXY_CLASSES.computeIfAbsent(signature, k -> { Preconditions.checkArgument(constructorArgs.length == constructorArgTypes.length, @@ -130,22 +155,9 @@ public static T createProxy( ) ); Preconditions.checkArgument(!listeners.isEmpty(), "The collection of listeners must not be empty"); - requireNonNull(cls, "Class must not be null"); - Preconditions.checkArgument(!cls.isInterface(), "Class must not be an interface"); ElementMatcher.Junction matcher = ElementMatchers.isPublic(); - //noinspection resource - return new ByteBuddy() - .subclass(cls) - .method(extraMethodMatcher == null ? matcher : matcher.and(extraMethodMatcher)) - .intercept(MethodDelegation.to(Interceptor.class)) - // https://github.com/raphw/byte-buddy/blob/2caef35c172897cbdd21d163c55305a64649ce41/byte-buddy-dep/src/test/java/net/bytebuddy/ByteBuddyTutorialExamplesTest.java#L346 - .defineField("methodCallListeners", MethodCallListener[].class, Visibility.PRIVATE) - .implement(HasMethodCallListeners.class).intercept(FieldAccessor.ofBeanProperty()) - .make() - .load(Helpers.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) - .getLoaded() - .asSubclass(cls); + return defineProxyClass(cls, extraMethodMatcher == null ? matcher : matcher.and(extraMethodMatcher)); }); try { @@ -171,9 +183,11 @@ public static T createProxy( * **public** method of the given class is invoked. Overridden callbacks * are expected to be skipped if they throw NotImplementedException. * - * @param cls the class to which the proxy should be created. - * Must not be an interface. Must expose a constructor - * without arguments. + * @param cls the class to proxy, or a public interface. + * A class must expose a constructor without arguments. + * A non-public interface is rejected with {@link IllegalArgumentException}. + * A public interface is also rejected unless this library's loader can see it, + * or its loader can see this library. * @param listeners One or more method invocation listeners. * @param Any class derived from Object * @return Proxy instance @@ -190,9 +204,11 @@ public static T createProxy(Class cls, Collection lis * **public** method of the given class is invoked. Overridden callbacks * are expected to be skipped if they throw NotImplementedException. * - * @param cls the class to which the proxy should be created. - * Must not be an interface. Must expose a constructor - * without arguments. + * @param cls the class to proxy, or a public interface. + * A class must expose a constructor without arguments. + * A non-public interface is rejected with {@link IllegalArgumentException}. + * A public interface is also rejected unless this library's loader can see it, + * or its loader can see this library. * @param listener Method invocation listener. * @param Any class derived from Object * @return Proxy instance @@ -209,8 +225,11 @@ public static T createProxy(Class cls, MethodCallListener listener) { * **public** method of the given class is invoked. Overridden callbacks * are expected to be skipped if they throw NotImplementedException. * - * @param cls the class to which the proxy should be created. - * Must not be an interface. + * @param cls the class to proxy, or a public interface. + * A non-public interface is rejected with + * {@link IllegalArgumentException}. A public interface is also + * rejected unless this library's loader can see it, or its loader + * can see this library. * @param constructorArgs Array of constructor arguments. Could be an * empty array if the class provides a constructor without arguments. * @param constructorArgTypes Array of constructor argument types. Must @@ -228,6 +247,71 @@ public static T createProxy( return createProxy(cls, constructorArgs, constructorArgTypes, Collections.singletonList(listener)); } + /** + * Builds a proxy class for a class or a public interface. + * An interface proxy subclasses {@link Object} and is loaded where both sides are visible. + */ + private static Class defineProxyClass( + Class cls, + ElementMatcher methodMatcher + ) { + boolean interfaceProxy = cls.isInterface(); + ClassLoader loader = interfaceProxy ? loaderForInterfaceProxy(cls) : Helpers.class.getClassLoader(); + var byteBuddy = new ByteBuddy(); + var subclassed = byteBuddy.subclass(interfaceProxy ? Object.class : cls); + var instrumented = interfaceProxy ? subclassed.implement(cls) : subclassed; + //noinspection resource + return instrumented + .method(methodMatcher) + .intercept(MethodDelegation.to(Interceptor.class)) + // https://github.com/raphw/byte-buddy/blob/2caef35c172897cbdd21d163c55305a64649ce41/byte-buddy-dep/src/test/java/net/bytebuddy/ByteBuddyTutorialExamplesTest.java#L346 + .defineField("methodCallListeners", MethodCallListener[].class, Visibility.PRIVATE) + .implement(HasMethodCallListeners.class).intercept(FieldAccessor.ofBeanProperty()) + .make() + .load(loader, ClassLoadingStrategy.Default.WRAPPER) + .getLoaded() + .asSubclass(cls); + } + + /** + * Prefers the library loader, matching class proxies, when it can see the interface. + * Otherwise uses the interface loader when that loader can see {@link Interceptor}. + */ + private static ClassLoader loaderForInterfaceProxy(Class iface) { + ClassLoader libraryLoader = Helpers.class.getClassLoader(); + if (sameTypeVisible(libraryLoader, iface)) { + return libraryLoader; + } + ClassLoader interfaceLoader = iface.getClassLoader(); + if (interfaceLoader != null && sameTypeVisible(interfaceLoader, Interceptor.class)) { + return interfaceLoader; + } + throw new IllegalArgumentException(String.format( + "Cannot proxy %s. The generated proxy must see both this interface and %s. " + + "Interface class loader: %s. Library class loader: %s. " + + "Either this library's loader must see the interface, " + + "or the interface's loader must see this library.", + iface.getName(), + Interceptor.class.getName(), + interfaceLoader, + libraryLoader + )); + } + + private static boolean sameTypeVisible(ClassLoader loader, Class type) { + if (type.getClassLoader() == loader) { + return true; + } + if (loader == null) { + return false; + } + try { + return Class.forName(type.getName(), false, loader) == type; + } catch (ClassNotFoundException e) { + return false; + } + } + @Value(staticConstructor = "of") private static class ProxyClassSignature { Class cls; diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/InterfaceProxyLoadingTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/InterfaceProxyLoadingTest.java new file mode 100644 index 000000000..8bd674c2f --- /dev/null +++ b/src/test/java/io/appium/java_client/pagefactory_tests/InterfaceProxyLoadingTest.java @@ -0,0 +1,95 @@ +package io.appium.java_client.pagefactory_tests; + +import io.appium.java_client.proxy.Helpers; +import io.appium.java_client.proxy.MethodCallListener; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.description.modifier.Visibility; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Map; +import java.util.concurrent.Callable; + +import static io.appium.java_client.pagefactory.utils.ProxyFactory.getEnhancedInterfaceProxy; +import static org.junit.jupiter.api.Assertions.assertEquals; +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; + +class InterfaceProxyLoadingTest { + + public interface Probe { + String ping(); + } + + @Test + void reusesOneProxyClassForTheSameInterface() { + MethodCallListener listener = new MethodCallListener() { + }; + Object first = getEnhancedInterfaceProxy(Probe.class, listener); + Object second = getEnhancedInterfaceProxy(Probe.class, listener); + + assertSame(first.getClass(), second.getClass()); + } + + @Test + void cacheKeyIsNotTheInterfaceClass() throws Exception { + getEnhancedInterfaceProxy(Probe.class, new MethodCallListener() { + }); + Field cacheField = Helpers.class.getDeclaredField("CACHED_PROXY_CLASSES"); + cacheField.setAccessible(true); + Map cache = (Map) cacheField.get(null); + + assertTrue(cache.keySet().stream().noneMatch(key -> key instanceof Class)); + assertThrows(NoSuchFieldException.class, () -> Class + .forName("io.appium.java_client.pagefactory.utils.ProxyFactory") + .getDeclaredField("CACHED_INTERFACE_PROXIES")); + } + + @Test + void proxiesPublicInterfaceLoadedByAChildOfTheLibraryLoader() throws Exception { + ClassLoader child = new URLClassLoader(new URL[0], Helpers.class.getClassLoader()); + Class iface = new ByteBuddy() + .makeInterface() + .name("io.appium.java_client.pagefactory_tests.ChildProbe") + .defineMethod("ping", String.class, Visibility.PUBLIC) + .withoutCode() + .make() + .load(child, ClassLoadingStrategy.Default.WRAPPER) + .getLoaded(); + Object proxy = getEnhancedInterfaceProxy(iface, new MethodCallListener() { + @Override + public Object call(Object obj, Method method, Object[] args, Callable original) { + return "ping".equals(method.getName()) ? "pong" : MethodCallListener.UNSET; + } + }); + + assertEquals("pong", iface.getMethod("ping").invoke(proxy)); + } + + @Test + void rejectsInterfaceWhoseLoaderCannotSeeTheLibrary() throws Exception { + ClassLoader isolated = new URLClassLoader(new URL[0], ClassLoader.getPlatformClassLoader()); + Class iface = new ByteBuddy() + .makeInterface() + .name("io.appium.java_client.pagefactory_tests.IsolatedProbe") + .defineMethod("ping", String.class, Visibility.PUBLIC) + .withoutCode() + .make() + .load(isolated, ClassLoadingStrategy.Default.WRAPPER) + .getLoaded(); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> getEnhancedInterfaceProxy(iface, new MethodCallListener() { + }) + ); + assertTrue(error.getMessage().contains("class loader")); + assertNull(error.getCause()); + } +} diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/WidgetMultipleLocatorLookupTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/WidgetMultipleLocatorLookupTest.java index 75d9b9370..692733b1e 100644 --- a/src/test/java/io/appium/java_client/pagefactory_tests/WidgetMultipleLocatorLookupTest.java +++ b/src/test/java/io/appium/java_client/pagefactory_tests/WidgetMultipleLocatorLookupTest.java @@ -22,6 +22,7 @@ import io.appium.java_client.pagefactory.AppiumFieldDecorator; import io.appium.java_client.pagefactory.HowToUseLocators; import io.appium.java_client.pagefactory.LocatorGroupStrategy; +import io.appium.java_client.pagefactory.OverrideWidget; import io.appium.java_client.pagefactory.SearchingWidgetRoot; import io.appium.java_client.pagefactory.Widget; import io.appium.java_client.pagefactory.WidgetInterceptor; @@ -98,6 +99,15 @@ public void nestedElementIsFoundUnderALaterWidgetLocator() { assertFoundAcrossWidgets(new SamplePage(), new SelectiveDriver(WIDGET_1, WIDGET_2)); } + /** + * An interface field uses the same later-root search as a class field. + * The concrete widget is still built on a {@link SearchingWidgetRoot}. + */ + @Test + public void nestedElementIsFoundUnderALaterRootForAnInterfaceWidget() { + assertFoundAcrossWidgets(new InterfaceSamplePage(), new SelectiveDriver(WIDGET_1, WIDGET_2)); + } + /** * {@code @AndroidFindAll} is the other way to declare the same set of alternative locators. */ @@ -566,6 +576,40 @@ public String readNestedElement() { } } + /** + * Same nested lookup as {@link SampleWidget}, selected through an interface field. + */ + public interface RootedWidget { + /** + * Reads the nested element. + * + * @return the nested element text + */ + String read(); + } + + public static class InterfaceSampleWidget extends SampleWidget implements RootedWidget { + protected InterfaceSampleWidget(WebElement element) { + super(element); + } + } + + public static class InterfaceSamplePage implements Readable { + + @HowToUseLocators(androidAutomation = ALL_POSSIBLE) + @AndroidFindBy(id = WIDGET_1) + @AndroidFindBy(id = WIDGET_2) + @OverrideWidget(androidUIAutomator = InterfaceSampleWidget.class, + iOSXCUITAutomation = InterfaceSampleWidget.class, + html = InterfaceSampleWidget.class) + private RootedWidget widget; + + @Override + public String readNestedElement() { + return widget.read(); + } + } + public static class SamplePage implements Readable { @HowToUseLocators(androidAutomation = ALL_POSSIBLE) diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/OverriddenWidgetInterfaceTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/OverriddenWidgetInterfaceTest.java new file mode 100644 index 000000000..00be00f76 --- /dev/null +++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/OverriddenWidgetInterfaceTest.java @@ -0,0 +1,402 @@ +package io.appium.java_client.pagefactory_tests.widget.tests.combined; + +import io.appium.java_client.pagefactory.AndroidFindBy; +import io.appium.java_client.pagefactory.AppiumFieldDecorator; +import io.appium.java_client.pagefactory.OverrideWidget; +import io.appium.java_client.pagefactory.Widget; +import io.appium.java_client.pagefactory.WidgetInterceptor; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import io.appium.java_client.pagefactory_tests.widget.tests.AbstractStubWebDriver; +import io.appium.java_client.proxy.HasMethodCallListeners; +import io.appium.java_client.proxy.MethodCallListener; +import io.appium.java_client.support.FindBy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Stream; + +import static io.appium.java_client.support.PageFactory.initElements; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverriddenWidgetInterfaceTest { + + static Stream drivers() { + return Stream.of( + Arguments.of(new AbstractStubWebDriver.StubAndroidDriver(), "android"), + Arguments.of(new AbstractStubWebDriver.StubIOSXCUITDriver(), "ios"), + Arguments.of(new AbstractStubWebDriver.StubBrowserDriver(), "html"), + Arguments.of(new AbstractStubWebDriver.StubAndroidBrowserOrWebViewDriver(), "html") + ); + } + + @ParameterizedTest + @MethodSource("drivers") + void delegatesInterfaceMethodToPlatformWidget(WebDriver driver, String expected) { + var page = new Page(); + initElements(new AppiumFieldDecorator(driver), page); + + assertEquals(expected, page.widget.platformAction()); + assertEquals(expected + "-title", page.widget.title()); + assertEquals(2, page.widgets.size()); + page.widgets.forEach(widget -> assertEquals(expected, widget.platformAction())); + } + + @Test + void defaultMethodNotOverriddenRunsOnTheRealWidgetForSingleAndList() { + var page = new Page(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertEquals("AndroidWidget:android", page.widget.described()); + assertEquals("AndroidWidget:android", page.widgets.get(0).described()); + } + + @Test + void singleFieldObjectMethodsUseTheLocatorAndProxyIdentity() { + var page = new Page(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertTrue(page.widget.toString().contains("Located by")); + assertTrue(page.widget.equals(page.widget)); + assertFalse(page.widget.equals(null)); + assertEquals(page.widget.hashCode(), page.widget.hashCode()); + } + + @Test + void listItemObjectMethodsAreDispatchedToTheWidget() { + var page = new IdentityPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + IdentityContract item = page.widgets.get(0); + + assertEquals("id-widget", item.toString()); + assertEquals(17, item.hashCode()); + assertTrue(item.equals("token")); + assertFalse(item.equals("other")); + } + + @Test + void listItemWorksAfterItsWeakElementReferenceIsCleared() throws Exception { + var page = new Page(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + ActionWidget item = page.widgets.get(0); + clearWeakElementReference(item); + + assertEquals("android", item.platformAction()); + } + + @Test + void nestedFindByOnTheConcreteWidgetIsInitialized() { + var page = new NestedPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertTrue(page.widget.labelText().contains("label")); + } + + @Test + void packagePrivateInterfaceFailsBeforeProxyGeneration() { + var page = new HiddenPage(); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page) + ); + assertTrue(error.getMessage().contains("public")); + assertNull(error.getCause()); + } + + private static void clearWeakElementReference(Object proxy) throws Exception { + MethodCallListener listener = ((HasMethodCallListeners) proxy).getMethodCallListeners()[0]; + Field reference = WidgetInterceptor.class.getDeclaredField("cachedElementReference"); + reference.setAccessible(true); + reference.set(listener, new WeakReference(null)); + } + + @Test + void usesLocatorDeclaredOnThePlatformWidgetClass() { + var page = new ClassLocatedPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertEquals("android", page.widget.platformAction()); + assertTrue(page.widget.toString().contains("android-root")); + assertEquals(2, page.widgets.size()); + page.widgets.forEach(widget -> assertEquals("android", widget.platformAction())); + } + + @Test + void allowsPartialOverrideForTheCurrentContent() { + var page = new IosOnlyPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubIOSXCUITDriver()), page); + + assertEquals("ios", page.widget.platformAction()); + } + + @Test + void missingContentOverrideFailsWhenThatContentIsUsed() { + var page = new IosOnlyPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + InstantiationException error = assertThrows(InstantiationException.class, () -> page.widget.platformAction()); + assertTrue(error.getMessage().contains("NATIVE_MOBILE_SPECIFIC")); + } + + @Test + void rejectsOverrideClassThatDoesNotImplementTheInterface() { + var page = new MismatchedPage(); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page) + ); + assertTrue(error.getCause().getMessage().contains("is not assignable from")); + } + + @Test + void leavesUnannotatedInterfaceFieldUnset() { + var page = new PlainPage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertNull(page.untouched); + } + + static class Page { + @OverrideWidget(html = HtmlWidget.class, + androidUIAutomator = AndroidWidget.class, + iOSXCUITAutomation = IosWidget.class) + @FindBy(id = "widget") + private ActionWidget widget; + + @OverrideWidget(html = HtmlWidget.class, + androidUIAutomator = AndroidWidget.class, + iOSXCUITAutomation = IosWidget.class) + @FindBy(id = "widget") + private List widgets; + } + + static class ClassLocatedPage { + @OverrideWidget(html = HtmlWidget.class, + androidUIAutomator = AndroidWidget.class, + iOSXCUITAutomation = IosWidget.class) + private ActionWidget widget; + + @OverrideWidget(html = HtmlWidget.class, + androidUIAutomator = AndroidWidget.class, + iOSXCUITAutomation = IosWidget.class) + private List widgets; + } + + static class IosOnlyPage { + @OverrideWidget(iOSXCUITAutomation = IosWidget.class) + @FindBy(id = "widget") + private ActionWidget widget; + } + + static class MismatchedPage { + @OverrideWidget(androidUIAutomator = UnrelatedWidget.class, + iOSXCUITAutomation = IosWidget.class, + html = HtmlWidget.class) + @FindBy(id = "widget") + private ActionWidget widget; + } + + static class PlainPage { + private ActionWidget untouched; + } + + static class IdentityPage { + @OverrideWidget(html = IdentityHtmlWidget.class, + androidUIAutomator = IdentityAndroidWidget.class, + iOSXCUITAutomation = IdentityIosWidget.class) + @FindBy(id = "widget") + private List widgets; + } + + static class NestedPage { + @OverrideWidget(html = NestedHtmlWidget.class, + androidUIAutomator = NestedAndroidWidget.class, + iOSXCUITAutomation = NestedIosWidget.class) + private NestedContract widget; + } + + static class HiddenPage { + @OverrideWidget(html = HiddenWidget.class, + androidUIAutomator = HiddenWidget.class, + iOSXCUITAutomation = HiddenWidget.class) + @FindBy(id = "widget") + private PackagePrivateWidget widget; + } + + public interface ActionWidget { + String platformAction(); + + default String title() { + return "default"; + } + + /** + * Not overridden by the platform widgets. {@code getClass} shows which instance ran it. + */ + default String described() { + return getClass().getSimpleName() + ":" + platformAction(); + } + } + + public interface IdentityContract { + String platformAction(); + } + + public interface NestedContract { + String labelText(); + } + + @AndroidFindBy(uiAutomator = "android-root") + public static class AndroidWidget extends Widget implements ActionWidget { + protected AndroidWidget(WebElement element) { + super(element); + } + + @Override + public String platformAction() { + return "android"; + } + + @Override + public String title() { + return "android-title"; + } + } + + @iOSXCUITFindBy(iOSNsPredicate = "ios-root") + public static class IosWidget extends Widget implements ActionWidget { + protected IosWidget(WebElement element) { + super(element); + } + + @Override + public String platformAction() { + return "ios"; + } + + @Override + public String title() { + return "ios-title"; + } + } + + public static class HtmlWidget extends Widget implements ActionWidget { + protected HtmlWidget(WebElement element) { + super(element); + } + + @Override + public String platformAction() { + return "html"; + } + + @Override + public String title() { + return "html-title"; + } + } + + public static class UnrelatedWidget extends Widget { + protected UnrelatedWidget(WebElement element) { + super(element); + } + } + + public static class IdentityAndroidWidget extends Widget implements IdentityContract { + protected IdentityAndroidWidget(WebElement element) { + super(element); + } + + @Override + public String platformAction() { + return "android"; + } + + @Override + public String toString() { + return "id-widget"; + } + + @Override + public int hashCode() { + return 17; + } + + @Override + public boolean equals(Object obj) { + return "token".equals(obj); + } + } + + public static class IdentityIosWidget extends IdentityAndroidWidget { + protected IdentityIosWidget(WebElement element) { + super(element); + } + } + + public static class IdentityHtmlWidget extends IdentityAndroidWidget { + protected IdentityHtmlWidget(WebElement element) { + super(element); + } + } + + @AndroidFindBy(uiAutomator = "android-root") + public static class NestedAndroidWidget extends Widget implements NestedContract { + @FindBy(id = "label") + private WebElement label; + + protected NestedAndroidWidget(WebElement element) { + super(element); + } + + @Override + public String labelText() { + return label.toString(); + } + } + + public static class NestedIosWidget extends Widget implements NestedContract { + protected NestedIosWidget(WebElement element) { + super(element); + } + + @Override + public String labelText() { + return "ios"; + } + } + + public static class NestedHtmlWidget extends Widget implements NestedContract { + protected NestedHtmlWidget(WebElement element) { + super(element); + } + + @Override + public String labelText() { + return "html"; + } + } + + public static class HiddenWidget extends Widget implements PackagePrivateWidget { + protected HiddenWidget(WebElement element) { + super(element); + } + + @Override + public String platformAction() { + return "hidden"; + } + } +} diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/PackagePrivateWidget.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/PackagePrivateWidget.java new file mode 100644 index 000000000..04868007c --- /dev/null +++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/PackagePrivateWidget.java @@ -0,0 +1,5 @@ +package io.appium.java_client.pagefactory_tests.widget.tests.combined; + +interface PackagePrivateWidget { + String platformAction(); +}