diff --git a/docs/openrewrite/migrate-to-v11.yml b/docs/openrewrite/migrate-to-v11.yml index b795da72e..b6a36c3b5 100644 --- a/docs/openrewrite/migrate-to-v11.yml +++ b/docs/openrewrite/migrate-to-v11.yml @@ -145,6 +145,9 @@ recipeList: - org.openrewrite.java.ChangeType: oldFullyQualifiedTypeName: org.openqa.selenium.support.pagefactory.AbstractAnnotations newFullyQualifiedTypeName: io.appium.java_client.support.pagefactory.AbstractAnnotations + - org.openrewrite.java.ChangeType: + oldFullyQualifiedTypeName: org.openqa.selenium.support.pagefactory.Annotations + newFullyQualifiedTypeName: io.appium.java_client.support.pagefactory.Annotations - org.openrewrite.java.ChangeType: oldFullyQualifiedTypeName: org.openqa.selenium.support.pagefactory.ByAll newFullyQualifiedTypeName: io.appium.java_client.support.pagefactory.ByAll @@ -172,6 +175,12 @@ recipeList: - org.openrewrite.java.ChangeType: oldFullyQualifiedTypeName: org.openqa.selenium.support.How newFullyQualifiedTypeName: io.appium.java_client.support.How + - org.openrewrite.java.ChangeType: + oldFullyQualifiedTypeName: org.openqa.selenium.support.PageFactoryFinder + newFullyQualifiedTypeName: io.appium.java_client.support.PageFactoryFinder + - org.openrewrite.java.ChangeType: + oldFullyQualifiedTypeName: org.openqa.selenium.support.AbstractFindByBuilder + newFullyQualifiedTypeName: io.appium.java_client.support.AbstractFindByBuilder --- type: specs.openrewrite.org/v1beta/recipe name: io.appium.java_client.FindV11Breakages diff --git a/docs/v10-to-v11-migration-guide.md b/docs/v10-to-v11-migration-guide.md index 4f728e374..327ba3c84 100644 --- a/docs/v10-to-v11-migration-guide.md +++ b/docs/v10-to-v11-migration-guide.md @@ -63,7 +63,7 @@ rewrite { | Recipe | What it does | |----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| | `io.appium.java_client.MigrateToV11` | Moves the types listed in the [first table](#what-moved) to their new packages. | -| `io.appium.java_client.MigrateToV11PageObjectAnnotations` | Optional. Switches `@FindBy`, `@FindBys`, `@FindAll`, `@CacheLookup` and `How` to the Appium ones. Skip it if the same code also uses the Selenium `PageFactory`. | +| `io.appium.java_client.MigrateToV11PageObjectAnnotations` | Optional. Switches `@FindBy`, `@FindBys`, `@FindAll`, `@CacheLookup`, `How`, `@PageFactoryFinder` and `AbstractFindByBuilder` to the Appium ones. Skip it if the same code also uses the Selenium `PageFactory`. | | `io.appium.java_client.FindV11Breakages` | Marks the usages that have no automatic replacement (`RemoteWebDriver`, `HasBiDi`, `BiDi`, `DriverService`, touch actions, `AppiumFunction`, the mobile command helpers) with `/*~~>*/`. Search for `MobileCommand` yourself, as only some of its constants are removed. | Several recipes can be activated at once, separated with commas. The recipe does not touch your build files @@ -82,8 +82,8 @@ These types are covered by the recipe. The names stay the same, only the package | `org.openqa.selenium.remote.HttpCommandExecutor` | `io.appium.java_client.remote.AppiumCommandExecutor` | | `org.openqa.selenium.remote.CommandInfo` | `io.appium.java_client.AppiumCommandInfo` | | `org.openqa.selenium.remote.http.{ClientConfig, HttpClient, HttpRequest, HttpResponse, HttpMethod, HttpHandler, HttpHeader, HttpMessage, Filter, Contents, WebSocket, Message, TextMessage, BinaryMessage, CloseMessage, ConnectionFailedException}` | `io.appium.java_client.http.*` | -| `org.openqa.selenium.support.{PageFactory}` and `org.openqa.selenium.support.pagefactory.{ElementLocator, ElementLocatorFactory, FieldDecorator, DefaultFieldDecorator, AbstractAnnotations, ByAll}` | `io.appium.java_client.support.*` and `io.appium.java_client.support.pagefactory.*` | -| `org.openqa.selenium.support.{FindBy, FindBys, FindAll, CacheLookup, How}` (optional, the Selenium ones keep working while `selenium-support` is available) | `io.appium.java_client.support.*` | +| `org.openqa.selenium.support.{PageFactory}` and `org.openqa.selenium.support.pagefactory.{ElementLocator, ElementLocatorFactory, FieldDecorator, DefaultFieldDecorator, AbstractAnnotations, Annotations, ByAll}` | `io.appium.java_client.support.*` and `io.appium.java_client.support.pagefactory.*` | +| `org.openqa.selenium.support.{FindBy, FindBys, FindAll, CacheLookup, How, PageFactoryFinder, AbstractFindByBuilder}` (optional, the Selenium ones keep working while `selenium-support` is available) | `io.appium.java_client.support.*` | | `io.appium.java_client.functions.ExpectedCondition` | `org.openqa.selenium.support.ui.ExpectedCondition` | ## What needs a manual change @@ -187,8 +187,9 @@ is not reset to the default anymore when other settings are changed. ### Page objects and waits - The page factory and `FluentWait` engine is now `io.appium.java_client.support`. Page objects that use the -Selenium `@FindBy`, `@FindBys`, `@FindAll` and `@CacheLookup` annotations keep working if `selenium-support` -is available, but it is recommended to switch to the annotations from `io.appium.java_client.support`. +Selenium `@FindBy`, `@FindBys`, `@FindAll`, `@CacheLookup` and `@PageFactoryFinder` annotations keep working if +`selenium-support` is available, but it is recommended to switch to the annotations from +`io.appium.java_client.support`. See [Page objects](Page-objects.md). ### Touch actions diff --git a/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java b/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java index 86bd9c225..ee95d75db 100644 --- a/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java +++ b/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java @@ -21,18 +21,18 @@ import io.appium.java_client.pagefactory.bys.builder.AppiumByBuilder; import io.appium.java_client.pagefactory.bys.builder.ByChained; import io.appium.java_client.pagefactory.bys.builder.HowToUseSelectors; +import io.appium.java_client.support.AbstractFindByBuilder; import io.appium.java_client.support.ByIdOrName; import io.appium.java_client.support.CacheLookup; -import io.appium.java_client.support.FindAll; -import io.appium.java_client.support.FindBy; -import io.appium.java_client.support.FindBys; import io.appium.java_client.support.PageFactory; +import io.appium.java_client.support.PageFactoryFinder; import io.appium.java_client.support.pagefactory.ByAll; import org.jspecify.annotations.Nullable; import org.openqa.selenium.By; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -91,16 +91,81 @@ public DefaultElementByBuilder( ); } - private static void checkDisallowedAnnotationPairs(Annotation a1, Annotation a2) + private static void validateFinderAnnotations(List finders) throws IllegalArgumentException { - if (a1 != null && a2 != null) { + if (finders.size() > 1) { + Annotation a1 = finders.get(0); + Annotation a2 = finders.get(1); throw new IllegalArgumentException( - "If you use a '@" + a1.getClass().getSimpleName() + "' annotation, " - + "you must not also use a '@" + a2.getClass().getSimpleName() + "If you use a '@" + a1.annotationType().getSimpleName() + "' annotation, " + + "you must not also use a '@" + a2.annotationType().getSimpleName() + "' annotation"); } } + private static List resolveFinderAnnotations(AnnotatedElement annotatedElement) { + List declared = collectFinderAnnotations( + annotatedElement.getDeclaredAnnotations(), annotatedElement + ); + if (!declared.isEmpty() || !(annotatedElement instanceof Class)) { + return declared; + } + return collectFinderAnnotations(annotatedElement.getAnnotations(), annotatedElement); + } + + private static List collectFinderAnnotations( + Annotation[] candidates, + AnnotatedElement annotatedElement + ) { + List finders = new ArrayList<>(); + for (Annotation annotation : candidates) { + if (annotation.annotationType().isAnnotationPresent(PageFactoryFinder.class)) { + finders.add(annotation); + } else if (SeleniumAnnotationsCompat.isBuiltinSeleniumFinder(annotation)) { + Annotation adapted = SeleniumAnnotationsCompat.adaptBuiltinSeleniumFinder( + annotatedElement, annotation + ); + if (adapted != null) { + finders.add(adapted); + } + } else if (SeleniumAnnotationsCompat.hasLegacyPageFactoryFinder(annotation)) { + finders.add(annotation); + } + } + return finders; + } + + @Nullable + @SuppressWarnings("unchecked") + private static By buildFromFinderAnnotation(Annotation annotation, @Nullable Field field) { + PageFactoryFinder finder = annotation.annotationType().getAnnotation(PageFactoryFinder.class); + if (finder != null) { + Class> builderClass = finder.value(); + AbstractFindByBuilder builder; + try { + Constructor> constructor = + builderClass.getDeclaredConstructor(); + constructor.setAccessible(true); + builder = (AbstractFindByBuilder) constructor.newInstance(); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IllegalStateException(cause); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot instantiate PageFactoryFinder builder " + builderClass.getName(), e + ); + } + return builder.buildIt(annotation, field); + } + return SeleniumAnnotationsCompat.buildByFromLegacyFinder(annotation, field); + } + private static By buildMobileBy(LocatorGroupStrategy locatorGroupStrategy, By[] bys) { if (bys.length == 0) { return null; @@ -114,38 +179,24 @@ private static By buildMobileBy(LocatorGroupStrategy locatorGroupStrategy, By[] @Override protected void assertValidAnnotations() { AnnotatedElement annotatedElement = annotatedElementContainer.getAnnotated(); - FindBy findBy = SeleniumAnnotationsCompat.find(annotatedElement, FindBy.class); - FindBys findBys = SeleniumAnnotationsCompat.find(annotatedElement, FindBys.class); - checkDisallowedAnnotationPairs(findBy, findBys); - FindAll findAll = SeleniumAnnotationsCompat.find(annotatedElement, FindAll.class); - checkDisallowedAnnotationPairs(findBy, findAll); - checkDisallowedAnnotationPairs(findBys, findAll); + if (annotatedElement != null) { + validateFinderAnnotations(resolveFinderAnnotations(annotatedElement)); + } } @Override protected By buildDefaultBy() { AnnotatedElement annotatedElement = annotatedElementContainer.getAnnotated(); - Field field = annotatedElement instanceof Field ? (Field) annotatedElement : null; - By defaultBy = null; - FindBy findBy = SeleniumAnnotationsCompat.find(annotatedElement, FindBy.class); - if (findBy != null) { - defaultBy = new FindBy.FindByBuilder().buildIt(findBy, field); - } - - if (defaultBy == null) { - FindBys findBys = SeleniumAnnotationsCompat.find(annotatedElement, FindBys.class); - if (findBys != null) { - defaultBy = new FindBys.FindByBuilder().buildIt(findBys, field); - } + if (annotatedElement == null) { + return null; } - - if (defaultBy == null) { - FindAll findAll = SeleniumAnnotationsCompat.find(annotatedElement, FindAll.class); - if (findAll != null) { - defaultBy = new FindAll.FindByBuilder().buildIt(findAll, field); - } + List finders = resolveFinderAnnotations(annotatedElement); + validateFinderAnnotations(finders); + if (finders.isEmpty()) { + return null; } - return defaultBy; + Field field = annotatedElement instanceof Field ? (Field) annotatedElement : null; + return buildFromFinderAnnotation(finders.get(0), field); } private By[] getBys(Class singleLocator, Class chainedLocator, diff --git a/src/main/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompat.java b/src/main/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompat.java index e03c2e148..41cb56ac9 100644 --- a/src/main/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompat.java +++ b/src/main/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompat.java @@ -16,16 +16,25 @@ package io.appium.java_client.pagefactory; +import io.appium.java_client.support.FindAll; +import io.appium.java_client.support.FindBy; +import io.appium.java_client.support.FindBys; import org.jspecify.annotations.Nullable; +import org.openqa.selenium.By; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.Arrays; +import static java.util.Objects.requireNonNull; + /** * Lets page objects that still use Selenium's {@code @FindBy}, {@code @FindBys}, {@code @FindAll} * and {@code @CacheLookup} keep working when selenium-support is on the classpath. @@ -34,6 +43,10 @@ */ final class SeleniumAnnotationsCompat { private static final String SELENIUM_SUPPORT_PACKAGE = "org.openqa.selenium.support."; + private static final String SELENIUM_PAGE_FACTORY_FINDER = SELENIUM_SUPPORT_PACKAGE + "PageFactoryFinder"; + private static final String SELENIUM_FIND_BY = SELENIUM_SUPPORT_PACKAGE + "FindBy"; + private static final String SELENIUM_FIND_BYS = SELENIUM_SUPPORT_PACKAGE + "FindBys"; + private static final String SELENIUM_FIND_ALL = SELENIUM_SUPPORT_PACKAGE + "FindAll"; private SeleniumAnnotationsCompat() { } @@ -63,6 +76,92 @@ static boolean isPresent(AnnotatedElement element, Class t return find(element, type) != null; } + static boolean isBuiltinSeleniumFinder(Annotation annotation) { + String name = annotation.annotationType().getName(); + return SELENIUM_FIND_BY.equals(name) + || SELENIUM_FIND_BYS.equals(name) + || SELENIUM_FIND_ALL.equals(name); + } + + @Nullable + static Annotation adaptBuiltinSeleniumFinder(AnnotatedElement element, Annotation annotation) { + String name = annotation.annotationType().getName(); + if (SELENIUM_FIND_BY.equals(name)) { + return element.getAnnotation(FindBy.class) != null ? null : adapt(annotation, FindBy.class); + } + if (SELENIUM_FIND_BYS.equals(name)) { + return element.getAnnotation(FindBys.class) != null ? null : adapt(annotation, FindBys.class); + } + if (SELENIUM_FIND_ALL.equals(name)) { + return element.getAnnotation(FindAll.class) != null ? null : adapt(annotation, FindAll.class); + } + return null; + } + + static boolean hasLegacyPageFactoryFinder(Annotation annotation) { + return findLegacyFinderMeta(annotation.annotationType()) != null; + } + + @Nullable + static By buildByFromLegacyFinder(Annotation annotation, @Nullable Field field) { + Annotation legacyFinder = requireNonNull(findLegacyFinderMeta(annotation.annotationType())); + Class builderClass; + try { + builderClass = (Class) legacyFinder.annotationType().getMethod("value").invoke(legacyFinder); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot read builder class from " + legacyFinder.annotationType().getName(), e + ); + } + + Object builder; + try { + Constructor constructor = builderClass.getDeclaredConstructor(); + constructor.setAccessible(true); + builder = constructor.newInstance(); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot instantiate PageFactoryFinder builder " + builderClass.getName(), e + ); + } + + try { + Method buildIt = builderClass.getMethod("buildIt", Object.class, Field.class); + buildIt.setAccessible(true); + return (By) buildIt.invoke(builder, annotation, field); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot invoke buildIt on PageFactoryFinder builder " + builderClass.getName(), e + ); + } + } + + @Nullable + private static Annotation findLegacyFinderMeta(Class annotationType) { + for (Annotation meta : annotationType.getAnnotations()) { + if (SELENIUM_PAGE_FACTORY_FINDER.equals(meta.annotationType().getName())) { + return meta; + } + } + return null; + } + + private static RuntimeException rethrowUnchecked(@Nullable Throwable cause) { + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + return new IllegalStateException(cause); + } + private static boolean isSeleniumCounterpart(Annotation annotation, Class type) { return annotation.annotationType().getName().equals(SELENIUM_SUPPORT_PACKAGE + type.getSimpleName()); } diff --git a/src/main/java/io/appium/java_client/support/FindAll.java b/src/main/java/io/appium/java_client/support/FindAll.java index e7f6ad08d..7034a90e3 100644 --- a/src/main/java/io/appium/java_client/support/FindAll.java +++ b/src/main/java/io/appium/java_client/support/FindAll.java @@ -42,6 +42,7 @@ */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) +@PageFactoryFinder(FindAll.FindByBuilder.class) public @interface FindAll { /** * The locators to search with. diff --git a/src/main/java/io/appium/java_client/support/FindBy.java b/src/main/java/io/appium/java_client/support/FindBy.java index a34b71652..39fd03c95 100644 --- a/src/main/java/io/appium/java_client/support/FindBy.java +++ b/src/main/java/io/appium/java_client/support/FindBy.java @@ -45,6 +45,7 @@ */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) +@PageFactoryFinder(FindBy.FindByBuilder.class) public @interface FindBy { /** * The location strategy to use together with {@link #using()}. diff --git a/src/main/java/io/appium/java_client/support/FindBys.java b/src/main/java/io/appium/java_client/support/FindBys.java index cbfb20660..7abb7a1c3 100644 --- a/src/main/java/io/appium/java_client/support/FindBys.java +++ b/src/main/java/io/appium/java_client/support/FindBys.java @@ -41,6 +41,7 @@ */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) +@PageFactoryFinder(FindBys.FindByBuilder.class) public @interface FindBys { /** * The locators to chain. diff --git a/src/main/java/io/appium/java_client/support/PageFactoryFinder.java b/src/main/java/io/appium/java_client/support/PageFactoryFinder.java new file mode 100644 index 000000000..61278afc2 --- /dev/null +++ b/src/main/java/io/appium/java_client/support/PageFactoryFinder.java @@ -0,0 +1,15 @@ +package io.appium.java_client.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a custom locator annotation with its builder class. */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +public @interface PageFactoryFinder { + /** Specify the builder implementation used to construct a locator from the annotated type. + * @return the builder class */ + Class> value(); +} diff --git a/src/main/java/io/appium/java_client/support/pagefactory/Annotations.java b/src/main/java/io/appium/java_client/support/pagefactory/Annotations.java new file mode 100644 index 000000000..394020b13 --- /dev/null +++ b/src/main/java/io/appium/java_client/support/pagefactory/Annotations.java @@ -0,0 +1,211 @@ +package io.appium.java_client.support.pagefactory; + +import io.appium.java_client.support.AbstractFindByBuilder; +import io.appium.java_client.support.ByIdOrName; +import io.appium.java_client.support.CacheLookup; +import io.appium.java_client.support.FindAll; +import io.appium.java_client.support.FindBy; +import io.appium.java_client.support.FindBys; +import io.appium.java_client.support.PageFactoryFinder; +import org.jspecify.annotations.Nullable; +import org.openqa.selenium.By; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** Processes locator and cache annotations on a page object field. */ +public class Annotations extends AbstractAnnotations { + private static final String SELENIUM_SUPPORT_PACKAGE = "org.openqa.selenium.support."; + private static final String SELENIUM_PAGE_FACTORY_FINDER = SELENIUM_SUPPORT_PACKAGE + "PageFactoryFinder"; + private static final String SELENIUM_CACHE_LOOKUP = SELENIUM_SUPPORT_PACKAGE + "CacheLookup"; + private static final String SELENIUM_FIND_BY = SELENIUM_SUPPORT_PACKAGE + "FindBy"; + private static final String SELENIUM_FIND_BYS = SELENIUM_SUPPORT_PACKAGE + "FindBys"; + private static final String SELENIUM_FIND_ALL = SELENIUM_SUPPORT_PACKAGE + "FindAll"; + + private final Field field; + + /** Creates an annotation processor for the given field. + * @param field the field to inspect */ + public Annotations(Field field) { + this.field = requireNonNull(field, "Field must not be null"); + } + + @Override + public boolean isLookupCached() { + if (field.getAnnotation(CacheLookup.class) != null) { + return true; + } + for (Annotation annotation : field.getAnnotations()) { + if (SELENIUM_CACHE_LOOKUP.equals(annotation.annotationType().getName())) { + return true; + } + } + return false; + } + + @Override + public By buildBy() { + assertValidAnnotations(); + + By ans = null; + List finders = collectFinderAnnotations(field); + if (!finders.isEmpty()) { + ans = buildFromFinder(finders.get(0), field); + } + if (ans == null) { + ans = buildByFromDefault(); + } + return ans; + } + + /** Gets the annotated field handled by this instance. + * @return the field */ + protected Field getField() { + return field; + } + + /** Builds the fallback locator when no locator annotation produces one. + * @return a locator using the field name */ + protected By buildByFromDefault() { + return new ByIdOrName(field.getName()); + } + + /** Verifies that mutually exclusive locator annotations are not combined on the field. */ + protected void assertValidAnnotations() { + List finders = collectFinderAnnotations(field); + if (finders.size() > 1) { + Annotation first = finders.get(0); + Annotation second = finders.get(1); + throw new IllegalArgumentException( + "If you use a '@" + first.annotationType().getSimpleName() + "' annotation, " + + "you must not also use a '@" + second.annotationType().getSimpleName() + + "' annotation" + ); + } + } + + static boolean hasLocatorAnnotation(Field field) { + for (Annotation annotation : field.getAnnotations()) { + Class annotationType = annotation.annotationType(); + if (annotationType.isAnnotationPresent(PageFactoryFinder.class) + || findLegacyFinderMeta(annotationType) != null) { + return true; + } + } + return false; + } + + private static List collectFinderAnnotations(Field field) { + List finders = new ArrayList<>(); + for (Annotation annotation : field.getDeclaredAnnotations()) { + Class annotationType = annotation.annotationType(); + if (annotationType.isAnnotationPresent(PageFactoryFinder.class)) { + finders.add(annotation); + } else if (findLegacyFinderMeta(annotationType) != null + && !isSupersededByAppiumCounterpart(field, annotationType)) { + finders.add(annotation); + } + } + return finders; + } + + private static boolean isSupersededByAppiumCounterpart( + Field field, + Class annotationType + ) { + String name = annotationType.getName(); + if (SELENIUM_FIND_BY.equals(name)) { + return field.getAnnotation(FindBy.class) != null; + } + if (SELENIUM_FIND_BYS.equals(name)) { + return field.getAnnotation(FindBys.class) != null; + } + if (SELENIUM_FIND_ALL.equals(name)) { + return field.getAnnotation(FindAll.class) != null; + } + return false; + } + + @Nullable + private static Annotation findLegacyFinderMeta(Class annotationType) { + for (Annotation meta : annotationType.getAnnotations()) { + if (SELENIUM_PAGE_FACTORY_FINDER.equals(meta.annotationType().getName())) { + return meta; + } + } + return null; + } + + @Nullable + @SuppressWarnings("unchecked") + private static By buildFromFinder(Annotation annotation, Field field) { + PageFactoryFinder finder = annotation.annotationType().getAnnotation(PageFactoryFinder.class); + if (finder != null) { + Class> builderClass = finder.value(); + AbstractFindByBuilder builder = + (AbstractFindByBuilder) instantiateBuilder(builderClass); + return builder.buildIt(annotation, field); + } + + Annotation legacyFinder = requireNonNull(findLegacyFinderMeta(annotation.annotationType())); + return invokeLegacyBuilder(legacyFinder, annotation, field); + } + + private static B instantiateBuilder(Class builderClass) { + try { + Constructor constructor = builderClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot instantiate PageFactoryFinder builder " + builderClass.getName(), e + ); + } + } + + @Nullable + private static By invokeLegacyBuilder(Annotation legacyFinder, Annotation annotation, Field field) { + Class builderClass; + try { + builderClass = (Class) legacyFinder.annotationType().getMethod("value").invoke(legacyFinder); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot read builder class from " + legacyFinder.annotationType().getName(), e + ); + } + + Object builder = instantiateBuilder(builderClass); + try { + Method buildIt = builderClass.getMethod("buildIt", Object.class, Field.class); + buildIt.setAccessible(true); + return (By) buildIt.invoke(builder, annotation, field); + } catch (InvocationTargetException e) { + throw rethrowUnchecked(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException( + "Cannot invoke buildIt on PageFactoryFinder builder " + builderClass.getName(), e + ); + } + } + + private static RuntimeException rethrowUnchecked(@Nullable Throwable cause) { + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + return new IllegalStateException(cause); + } +} diff --git a/src/main/java/io/appium/java_client/support/pagefactory/DefaultFieldDecorator.java b/src/main/java/io/appium/java_client/support/pagefactory/DefaultFieldDecorator.java index 3c0ae5622..f11863b55 100644 --- a/src/main/java/io/appium/java_client/support/pagefactory/DefaultFieldDecorator.java +++ b/src/main/java/io/appium/java_client/support/pagefactory/DefaultFieldDecorator.java @@ -16,9 +16,6 @@ package io.appium.java_client.support.pagefactory; -import io.appium.java_client.support.FindAll; -import io.appium.java_client.support.FindBy; -import io.appium.java_client.support.FindBys; import io.appium.java_client.support.PageFactory; import io.appium.java_client.support.pagefactory.internal.LocatingElementHandler; import io.appium.java_client.support.pagefactory.internal.LocatingElementListHandler; @@ -110,9 +107,7 @@ private boolean isDecoratableList(Field field, @Nullable Class pageClass) { } private static boolean hasStandardLocatorAnnotation(Field field) { - return field.getAnnotation(FindBy.class) != null - || field.getAnnotation(FindBys.class) != null - || field.getAnnotation(FindAll.class) != null; + return Annotations.hasLocatorAnnotation(field); } /** diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/CustomFindByAnnotationTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/CustomFindByAnnotationTest.java new file mode 100644 index 000000000..b65562ab9 --- /dev/null +++ b/src/test/java/io/appium/java_client/pagefactory_tests/CustomFindByAnnotationTest.java @@ -0,0 +1,520 @@ +package io.appium.java_client.pagefactory_tests; + +import io.appium.java_client.AppiumBy; +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.bys.ContentMappedBy; +import io.appium.java_client.pagefactory.bys.builder.ByChained; +import io.appium.java_client.pagefactory_tests.widget.tests.AbstractStubWebDriver; +import io.appium.java_client.pagefactory_tests.widget.tests.StubWebElement; +import io.appium.java_client.remote.AppiumWebElement; +import io.appium.java_client.remote.SupportsContextSwitching; +import io.appium.java_client.support.AbstractFindByBuilder; +import io.appium.java_client.support.FindBy; +import io.appium.java_client.support.PageFactoryFinder; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static io.appium.java_client.support.PageFactory.initElements; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 CustomFindByAnnotationTest { + + private static String buildScrollIntoViewExpression(String resourceId) { + return "new UiScrollable(new UiSelector().scrollable(true))" + + ".scrollIntoView(new UiSelector().resourceId(\"" + resourceId + "\"));"; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @Repeatable(ScrollingAndroidFindBys.class) + @PageFactoryFinder(ScrollingAndroidFindBy.Builder.class) + @interface ScrollingAndroidFindBy { + String scrollingResourceId(); + + class Builder extends AbstractFindByBuilder { + static Field lastSeenField = AbstractStubWebDriver.class.getDeclaredFields()[0]; + + @Override + public By buildIt(ScrollingAndroidFindBy annotation, @Nullable Field field) { + lastSeenField = field; + return AppiumBy.androidUIAutomator( + buildScrollIntoViewExpression(annotation.scrollingResourceId()) + ); + } + } + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @PageFactoryFinder(ScrollingAndroidFindBys.Builder.class) + @interface ScrollingAndroidFindBys { + ScrollingAndroidFindBy[] value(); + + class Builder extends AbstractFindByBuilder { + @Override + public By buildIt(ScrollingAndroidFindBys annotation, @Nullable Field field) { + ScrollingAndroidFindBy[] items = annotation.value(); + By[] bys = new By[items.length]; + ScrollingAndroidFindBy.Builder singleBuilder = new ScrollingAndroidFindBy.Builder(); + for (int i = 0; i < items.length; i++) { + bys[i] = singleBuilder.buildIt(items[i], field); + } + return new ByChained(bys); + } + } + } + + @Inherited + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @PageFactoryFinder(DataTestIdBuilder.class) + @interface DataTestId { + String value() default ""; + } + + private static final class DataTestIdBuilder extends AbstractFindByBuilder { + @Override + public By buildIt(DataTestId annotation, @Nullable Field field) { + String testId = annotation.value().isEmpty() && field != null + ? field.getName() + : annotation.value(); + return By.cssSelector("[data-testid='" + testId + "']"); + } + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @org.openqa.selenium.support.PageFactoryFinder(LegacyAccessibilityBuilder.class) + @interface LegacyAccessibility { + String value(); + } + + static final class LegacyAccessibilityBuilder + extends org.openqa.selenium.support.AbstractFindByBuilder { + @Override + public By buildIt(LegacyAccessibility annotation, Field field) { + return AppiumBy.accessibilityId(annotation.value()); + } + } + + private static class RecordingStubElement extends StubWebElement { + private final List recordedBys; + + RecordingStubElement(WebDriver driver, By by, List recordedBys) { + super(driver, by); + this.recordedBys = recordedBys; + } + + @Override + public RecordingStubElement findElement(By by) { + if (by instanceof ContentMappedBy) { + return (RecordingStubElement) by.findElement(this); + } + recordedBys.add(by); + return new RecordingStubElement(getWrappedDriver(), by, recordedBys); + } + + @Override + public List findElements(By by) { + if (by instanceof ContentMappedBy) { + return by.findElements(this); + } + recordedBys.add(by); + return List.of( + new RecordingStubElement(getWrappedDriver(), by, recordedBys), + new RecordingStubElement(getWrappedDriver(), by, recordedBys) + ); + } + } + + private static class RecordingAndroidDriver + extends AbstractStubWebDriver.StubAndroidDriver + implements SupportsContextSwitching { + private final List recordedBys = new ArrayList<>(); + private String currentContext = "NATIVE_APP"; + + @Override + public WebDriver context(String name) { + this.currentContext = name; + return this; + } + + @Override + public boolean isBrowser() { + return !"NATIVE_APP".equals(currentContext); + } + + @Override + public String getContext() { + return currentContext; + } + + @Override + public StubWebElement findElement(By by) { + if (by instanceof ContentMappedBy) { + return (StubWebElement) by.findElement(this); + } + recordedBys.add(by); + return new RecordingStubElement(this, by, recordedBys); + } + + @Override + public List findElements(By by) { + if (by instanceof ContentMappedBy) { + return by.findElements(this); + } + recordedBys.add(by); + return List.of( + new RecordingStubElement(this, by, recordedBys), + new RecordingStubElement(this, by, recordedBys) + ); + } + + By captureFirstBy(Runnable action) { + recordedBys.clear(); + action.run(); + return recordedBys.get(0); + } + } + + public static class ChildButtonWidget extends Widget { + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/innerAction") + private WebElement innerAction; + + protected ChildButtonWidget(WebElement element) { + super(element); + } + + public void tapInner() { + innerAction.click(); + } + } + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/classAnnotatedCard") + public static class ClassAnnotatedCardWidget extends Widget { + protected ClassAnnotatedCardWidget(WebElement element) { + super(element); + } + + public void tapRoot() { + getWrappedElement().click(); + } + } + + @DataTestId("inherited-card") + public static class InheritedBaseWidget extends Widget { + protected InheritedBaseWidget(WebElement element) { + super(element); + } + + public void tapRoot() { + getWrappedElement().click(); + } + } + + public static class InheritedSubWidget extends InheritedBaseWidget { + protected InheritedSubWidget(WebElement element) { + super(element); + } + } + + @DataTestId("overridden-sub-card") + public static class OverridingSubWidget extends InheritedBaseWidget { + protected OverridingSubWidget(WebElement element) { + super(element); + } + } + + static class CustomFinderPage { + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/buttonNotInView") + WebElement scrollingButton; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/appiumButton") + AppiumWebElement scrollingAppiumButton; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/listItem") + List scrollingItems; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/container") + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/nestedRow") + WebElement repeatedScrollingRow; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/byTarget") + By scrollingBy; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/appiumByTarget") + AppiumBy scrollingAppiumBy; + + @DataTestId + WebElement inferredFieldIdButton; + + @DataTestId("web-checkout") + @AndroidFindBy(id = "com.test:id/native_checkout") + WebElement hybridCheckoutButton; + + @DataTestId("web-checkout-by") + @AndroidFindBy(id = "com.test:id/native_checkout_by") + By hybridCheckoutBy; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/widgetContainer") + ChildButtonWidget fieldAnnotatedWidget; + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/widgetRow") + List fieldAnnotatedWidgets; + + ClassAnnotatedCardWidget classAnnotatedWidget; + + InheritedSubWidget inheritedWidget; + + OverridingSubWidget overridingWidget; + + @LegacyAccessibility("legacy_submit") + WebElement legacyElement; + + @LegacyAccessibility("legacy_list") + List legacyList; + + @LegacyAccessibility("legacy_widget") + ChildButtonWidget legacyWidget; + + @LegacyAccessibility("legacy_by") + AppiumBy legacyAppiumBy; + } + + static class ConflictingCustomAndFindByPage { + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/one") + @FindBy(id = "two") + WebElement invalidField; + } + + static class ConflictingTwoCustomFindersPage { + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/one") + @DataTestId("two") + By invalidByField; + } + + static class ConflictingCustomAndSeleniumFindByPage { + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/one") + @org.openqa.selenium.support.FindBy(id = "two") + WebElement invalidField; + } + + @ScrollingAndroidFindBy(scrollingResourceId = "com.test:id/one") + @FindBy(id = "two") + public static class ConflictingClassWidget extends Widget { + protected ConflictingClassWidget(WebElement element) { + super(element); + } + } + + static class ConflictingWidgetClassPage { + ConflictingClassWidget invalidWidget; + } + + @Test + void supportsCustomPageFactoryFinderAcrossElementsListsByFieldsAndWidgets() { + RecordingAndroidDriver driver = new RecordingAndroidDriver(); + CustomFinderPage page = new CustomFinderPage(); + initElements(new AppiumFieldDecorator(driver, Duration.ZERO), page); + + By expectedButtonBy = AppiumBy.androidUIAutomator( + buildScrollIntoViewExpression("com.test:id/buttonNotInView") + ); + assertEquals(expectedButtonBy, driver.captureFirstBy(page.scrollingButton::click)); + + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/appiumButton")), + driver.captureFirstBy(page.scrollingAppiumButton::click) + ); + + assertEquals(2, page.scrollingItems.size()); + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/listItem")), + driver.recordedBys.get(driver.recordedBys.size() - 1) + ); + + By expectedChained = new ByChained(new By[]{ + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/container")), + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/nestedRow")) + }); + assertEquals(expectedChained.toString(), driver.captureFirstBy(page.repeatedScrollingRow::click).toString()); + + assertInstanceOf(ContentMappedBy.class, page.scrollingBy); + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/byTarget")), + driver.captureFirstBy(() -> driver.findElement(page.scrollingBy)) + ); + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/appiumByTarget")), + page.scrollingAppiumBy + ); + + assertEquals( + By.cssSelector("[data-testid='inferredFieldIdButton']"), + driver.captureFirstBy(page.inferredFieldIdButton::click) + ); + + driver.recordedBys.clear(); + page.fieldAnnotatedWidget.tapInner(); + assertEquals( + List.of( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/widgetContainer")), + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/innerAction")) + ), + driver.recordedBys + ); + + assertEquals(2, page.fieldAnnotatedWidgets.size()); + driver.recordedBys.clear(); + page.fieldAnnotatedWidgets.get(0).tapInner(); + assertEquals( + List.of( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/widgetRow")), + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/innerAction")) + ), + driver.recordedBys + ); + } + + static class ClassOnlyWidgetPage { + ClassAnnotatedCardWidget classAnnotatedWidget; + } + + @Test + void supportsClassLevelInheritedAndOverriddenWidgetCustomAnnotations() { + RecordingAndroidDriver driver = new RecordingAndroidDriver(); + + ClassOnlyWidgetPage classPage = new ClassOnlyWidgetPage(); + ScrollingAndroidFindBy.Builder.lastSeenField = CustomFinderPage.class.getDeclaredFields()[0]; + initElements(new AppiumFieldDecorator(driver, Duration.ZERO), classPage); + assertNull(ScrollingAndroidFindBy.Builder.lastSeenField); + + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/classAnnotatedCard")), + driver.captureFirstBy(classPage.classAnnotatedWidget::tapRoot) + ); + + CustomFinderPage page = new CustomFinderPage(); + initElements(new AppiumFieldDecorator(driver, Duration.ZERO), page); + assertEquals( + By.cssSelector("[data-testid='inherited-card']"), + driver.captureFirstBy(page.inheritedWidget::tapRoot) + ); + assertEquals( + By.cssSelector("[data-testid='overridden-sub-card']"), + driver.captureFirstBy(page.overridingWidget::tapRoot) + ); + } + + @Test + void combinesCustomFinderWithMobileNativeAnnotationsAcrossContexts() { + RecordingAndroidDriver driver = new RecordingAndroidDriver(); + CustomFinderPage page = new CustomFinderPage(); + initElements(new AppiumFieldDecorator(driver, Duration.ZERO), page); + + assertEquals( + new ByChained(new By[]{AppiumBy.id("com.test:id/native_checkout")}).toString(), + driver.captureFirstBy(page.hybridCheckoutButton::click).toString() + ); + assertEquals( + new ByChained(new By[]{AppiumBy.id("com.test:id/native_checkout_by")}).toString(), + driver.captureFirstBy(() -> driver.findElement(page.hybridCheckoutBy)).toString() + ); + + driver.context("WEBVIEW_1"); + assertEquals( + By.cssSelector("[data-testid='web-checkout']"), + driver.captureFirstBy(page.hybridCheckoutButton::click) + ); + assertEquals( + By.cssSelector("[data-testid='web-checkout-by']"), + driver.captureFirstBy(() -> driver.findElement(page.hybridCheckoutBy)) + ); + assertEquals( + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/buttonNotInView")), + driver.captureFirstBy(page.scrollingButton::click) + ); + } + + @Test + void supportsLegacySeleniumPageFactoryFinderAnnotations() { + RecordingAndroidDriver driver = new RecordingAndroidDriver(); + CustomFinderPage page = new CustomFinderPage(); + initElements(new AppiumFieldDecorator(driver, Duration.ZERO), page); + + assertEquals( + AppiumBy.accessibilityId("legacy_submit"), + driver.captureFirstBy(page.legacyElement::click) + ); + assertEquals(2, page.legacyList.size()); + assertEquals( + AppiumBy.accessibilityId("legacy_list"), + driver.recordedBys.get(driver.recordedBys.size() - 1) + ); + + driver.recordedBys.clear(); + page.legacyWidget.tapInner(); + assertEquals( + List.of( + AppiumBy.accessibilityId("legacy_widget"), + AppiumBy.androidUIAutomator(buildScrollIntoViewExpression("com.test:id/innerAction")) + ), + driver.recordedBys + ); + + assertEquals(AppiumBy.accessibilityId("legacy_by"), page.legacyAppiumBy); + } + + @Test + void rejectsConflictingCustomAndBuiltinFinderAnnotations() { + RecordingAndroidDriver driver = new RecordingAndroidDriver(); + + IllegalArgumentException e1 = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(driver), new ConflictingCustomAndFindByPage()) + ); + assertTrue(e1.getMessage().contains("@ScrollingAndroidFindBy"), e1.getMessage()); + assertTrue(e1.getMessage().contains("@FindBy"), e1.getMessage()); + + IllegalArgumentException e2 = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(driver), new ConflictingTwoCustomFindersPage()) + ); + assertTrue(e2.getMessage().contains("@ScrollingAndroidFindBy"), e2.getMessage()); + assertTrue(e2.getMessage().contains("@DataTestId"), e2.getMessage()); + + IllegalArgumentException e3 = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(driver), new ConflictingCustomAndSeleniumFindByPage()) + ); + assertTrue(e3.getMessage().contains("@ScrollingAndroidFindBy"), e3.getMessage()); + assertTrue(e3.getMessage().contains("@FindBy"), e3.getMessage()); + + IllegalArgumentException e4 = assertThrows( + IllegalArgumentException.class, + () -> initElements(new AppiumFieldDecorator(driver), new ConflictingWidgetClassPage()) + ); + assertTrue(e4.getMessage().contains("@ScrollingAndroidFindBy"), e4.getMessage()); + assertTrue(e4.getMessage().contains("@FindBy"), e4.getMessage()); + } +} diff --git a/src/test/java/io/appium/java_client/support/AnnotationsTest.java b/src/test/java/io/appium/java_client/support/AnnotationsTest.java new file mode 100644 index 000000000..82d8156ce --- /dev/null +++ b/src/test/java/io/appium/java_client/support/AnnotationsTest.java @@ -0,0 +1,236 @@ +package io.appium.java_client.support; + +import io.appium.java_client.support.pagefactory.Annotations; +import io.appium.java_client.support.pagefactory.ByAll; +import io.appium.java_client.support.pagefactory.ByChained; +import io.appium.java_client.support.pagefactory.DefaultFieldDecorator; +import io.appium.java_client.support.pagefactory.ElementLocator; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 AnnotationsTest { + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @PageFactoryFinder(TestIdBuilder.class) + @interface TestId { + String value() default ""; + } + + private static final class TestIdBuilder extends AbstractFindByBuilder { + @Override + public By buildIt(TestId annotation, @Nullable Field field) { + String id = annotation.value().isEmpty() && field != null + ? field.getName() + : annotation.value(); + if ("__null__".equals(id)) { + return null; + } + if ("__invalid__".equals(id)) { + throw new IllegalArgumentException("Invalid test id"); + } + return By.cssSelector("[data-testid='" + id + "']"); + } + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @PageFactoryFinder(NoZeroArgConstructorBuilder.class) + @interface BrokenFinder { + } + + private static final class NoZeroArgConstructorBuilder extends AbstractFindByBuilder { + NoZeroArgConstructorBuilder(String unused) { + } + + @Override + public By buildIt(BrokenFinder annotation, @Nullable Field field) { + return By.id("unreachable"); + } + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.TYPE}) + @org.openqa.selenium.support.PageFactoryFinder(LegacyRoleBuilder.class) + @interface LegacyRole { + String value(); + } + + static final class LegacyRoleBuilder + extends org.openqa.selenium.support.AbstractFindByBuilder { + @Override + public By buildIt(LegacyRole annotation, Field field) { + return By.cssSelector("[role='" + annotation.value() + "']"); + } + } + + @SuppressWarnings("unused") + private static class SamplePage { + @FindBy(id = "save") + @CacheLookup + WebElement standard; + + @FindBys({@FindBy(id = "form"), @FindBy(name = "email")}) + WebElement chained; + + @FindAll({@FindBy(id = "primary"), @FindBy(id = "secondary")}) + WebElement any; + + @TestId("submit-btn") + WebElement customSingle; + + @TestId + List inferredItems; + + @TestId("__null__") + WebElement customNullFallback; + + @TestId("__invalid__") + WebElement customInvalid; + + @LegacyRole("listitem") + @org.openqa.selenium.support.CacheLookup + List legacyItems; + + @FindBy(id = "appium-wins") + @org.openqa.selenium.support.FindBy(id = "selenium-ignored") + WebElement bothBuiltins; + + @TestId("one") + @FindBy(id = "two") + WebElement conflicting; + + @BrokenFinder + WebElement brokenBuilderField; + + List unannotatedList; + + WebElement fallbackByName; + } + + private static Field field(String name) throws NoSuchFieldException { + return SamplePage.class.getDeclaredField(name); + } + + private static ElementLocator locatorFor(Field field) { + By by = new Annotations(field).buildBy(); + return new ElementLocator() { + @Override + public WebElement findElement() { + return Stubs.element(by.toString()); + } + + @Override + public List findElements() { + return List.of(Stubs.element(by.toString())); + } + }; + } + + @Test + void builtinAnnotationsCarryPageFactoryFinderMetadata() { + assertEquals(FindBy.FindByBuilder.class, FindBy.class.getAnnotation(PageFactoryFinder.class).value()); + assertEquals(FindBys.FindByBuilder.class, FindBys.class.getAnnotation(PageFactoryFinder.class).value()); + assertEquals(FindAll.FindByBuilder.class, FindAll.class.getAnnotation(PageFactoryFinder.class).value()); + } + + @Test + void buildsBuiltinCustomLegacyAndFallbackLocators() throws NoSuchFieldException { + Annotations standard = new Annotations(field("standard")); + assertEquals(By.id("save"), standard.buildBy()); + assertTrue(standard.isLookupCached()); + + assertInstanceOf(ByChained.class, new Annotations(field("chained")).buildBy()); + assertInstanceOf(ByAll.class, new Annotations(field("any")).buildBy()); + + assertEquals( + By.cssSelector("[data-testid='submit-btn']"), + new Annotations(field("customSingle")).buildBy() + ); + assertFalse(new Annotations(field("customSingle")).isLookupCached()); + + assertEquals( + By.cssSelector("[data-testid='inferredItems']"), + new Annotations(field("inferredItems")).buildBy() + ); + + assertInstanceOf(ByIdOrName.class, new Annotations(field("fallbackByName")).buildBy()); + assertInstanceOf(ByIdOrName.class, new Annotations(field("customNullFallback")).buildBy()); + + Annotations legacy = new Annotations(field("legacyItems")); + assertEquals(By.cssSelector("[role='listitem']"), legacy.buildBy()); + assertTrue(legacy.isLookupCached()); + + assertEquals(By.id("appium-wins"), new Annotations(field("bothBuiltins")).buildBy()); + } + + @Test + void decoratesSingleAndListFieldsAnnotatedWithCustomAndLegacyFinders() { + SamplePage page = new SamplePage(); + DefaultFieldDecorator decorator = new DefaultFieldDecorator(AnnotationsTest::locatorFor); + + for (String name : List.of("customSingle", "inferredItems", "legacyItems", "unannotatedList")) { + try { + Field f = field(name); + Object decorated = decorator.decorate(SamplePage.class.getClassLoader(), f); + if (decorated != null) { + f.setAccessible(true); + f.set(page, decorated); + } + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + assertNotNull(page.customSingle); + assertEquals(By.cssSelector("[data-testid='submit-btn']").toString(), page.customSingle.getText()); + assertNotNull(page.inferredItems); + assertEquals(1, page.inferredItems.size()); + assertEquals(By.cssSelector("[data-testid='inferredItems']").toString(), page.inferredItems.get(0).getText()); + assertNotNull(page.legacyItems); + assertEquals(By.cssSelector("[role='listitem']").toString(), page.legacyItems.get(0).getText()); + assertNull(page.unannotatedList); + } + + @Test + void rejectsConflictingAnnotationsAndInvalidBuilders() throws NoSuchFieldException { + Field conflicting = field("conflicting"); + IllegalArgumentException conflictError = assertThrows( + IllegalArgumentException.class, + () -> new Annotations(conflicting).buildBy() + ); + assertTrue(conflictError.getMessage().contains("@TestId"), conflictError.getMessage()); + assertTrue(conflictError.getMessage().contains("@FindBy"), conflictError.getMessage()); + + Field customInvalid = field("customInvalid"); + IllegalArgumentException builderValidationError = assertThrows( + IllegalArgumentException.class, + () -> new Annotations(customInvalid).buildBy() + ); + assertEquals("Invalid test id", builderValidationError.getMessage()); + + Field broken = field("brokenBuilderField"); + IllegalArgumentException brokenBuilderError = assertThrows( + IllegalArgumentException.class, + () -> new Annotations(broken).buildBy() + ); + assertTrue(brokenBuilderError.getMessage().contains("NoZeroArgConstructorBuilder")); + } +}