Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,34 @@

package io.appium.java_client.pagefactory;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.internal.CapabilityHelpers;
import io.appium.java_client.pagefactory.bys.ContentMappedBy;
import io.appium.java_client.pagefactory.bys.ContentType;
import io.appium.java_client.pagefactory.locator.CacheableLocator;
import io.appium.java_client.remote.AppiumWebElement;
import io.appium.java_client.remote.CapabilityType;
import io.appium.java_client.support.PageFactory;
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 io.appium.java_client.support.pagefactory.ElementLocatorFactory;
import io.appium.java_client.support.pagefactory.FieldDecorator;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.openqa.selenium.By;
import org.openqa.selenium.HasCapabilities;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.ref.WeakReference;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
Expand All @@ -54,15 +63,22 @@

/**
* Default decorator for use with PageFactory. Will decorate 1) all of the
* WebElement fields and 2) List of WebElement that have
* WebElement fields, 2) List of WebElement that have
* {@literal @AndroidFindBy}, {@literal @AndroidFindBys}, or
* {@literal @iOSFindBy/@iOSFindBys} annotation with a proxy that locates the
* elements using the passed in ElementLocatorFactory.
* Please pay attention: fields of {@link WebElement} or {@link AppiumWebElement}
* to use with this decorator
* elements using the passed in ElementLocatorFactory, and 3) non-static, non-final,
* uninitialized {@link By} / {@link AppiumBy} fields annotated with Appium or Selenium
* locator annotations (pre-initialized {@link By} fields are preserved when initialized via
* {@link PageFactory#initElements}). When a field is declared with a narrow {@link By} subtype
* (such as {@link AppiumBy}) and the built locator cannot be coerced to that subtype,
* a warning is logged and the field is left {@code null}.
* Please pay attention: fields of {@link WebElement}, {@link AppiumWebElement},
* {@link By}, or {@link AppiumBy} are supported by this decorator.
*/
public class AppiumFieldDecorator implements FieldDecorator {

private static final Logger LOG = LoggerFactory.getLogger(AppiumFieldDecorator.class);

private static final List<Class<? extends WebElement>> AVAILABLE_ELEMENT_CLASSES = List.of(
WebElement.class,
AppiumWebElement.class
Expand Down Expand Up @@ -265,6 +281,9 @@ public Object decorate(ClassLoader ignored, Field field) {
CURRENT_DEFAULT_STRATEGY.set(defaultLocatorGroupStrategy);
try {
Object result = decorateWidget(field);
if (result == null) {
result = decorateBy(field);
}
return result == null ? defaultElementFieldDecorator.decorate(ignored, field) : result;
} finally {
if (previous == LocatorGroupStrategy.CHAIN) {
Expand All @@ -275,6 +294,83 @@ public Object decorate(ClassLoader ignored, Field field) {
}
}

@Nullable
private Object decorateBy(Field field) {
Comment thread
mykola-mokhnach marked this conversation as resolved.
Resolved
Class<?> type = field.getType();
int modifiers = field.getModifiers();
if (!By.class.isAssignableFrom(type)
|| Modifier.isStatic(modifiers)
|| Modifier.isFinal(modifiers)
|| PageFactory.isCurrentPageFieldInitialized(field)) {
return null;
}

DefaultElementByBuilder builder = new DefaultElementByBuilder(
platform, automation, defaultLocatorGroupStrategy
);
builder.setAnnotated(field);
builder.assertValidAnnotations();

By mobileNativeBy = builder.buildMobileNativeBy();
By defaultBy = builder.buildDefaultBy();
if (mobileNativeBy == null && defaultBy == null) {
return null;
}
if (type.isAssignableFrom(ContentMappedBy.class)) {
return builder.buildMappedBy(defaultBy, mobileNativeBy);
}

By result = coerceToType(type, mobileNativeBy);
if (result == null) {
result = coerceToType(type, defaultBy);
}
if (result == null) {
By built = mobileNativeBy != null ? unwrapSingleBy(mobileNativeBy) : unwrapSingleBy(defaultBy);
LOG.warn(
"Cannot decorate field '{}.{}' of narrow type {}: built locator {} ({}) is not assignable to {}",
field.getDeclaringClass().getName(),
field.getName(),
type.getName(),
built,
built != null ? built.getClass().getName() : "null",
type.getName()
);
}
return result;
Comment thread
mykola-mokhnach marked this conversation as resolved.
Resolved
}

@Nullable
private static By coerceToType(Class<?> type, @Nullable By candidate) {
if (candidate == null) {
return null;
}
if (type.isInstance(candidate)) {
return candidate;
}
By unwrapped = unwrapSingleBy(candidate);
return type.isInstance(unwrapped) ? unwrapped : null;
}

@Nullable
private static By unwrapSingleBy(@Nullable By by) {
By current = by;
while (true) {
By[] bys;
if (current instanceof ByChained) {
bys = ((ByChained) current).getBys();
} else if (current instanceof ByAll) {
bys = ((ByAll) current).getBys();
} else {
break;
}
if (bys.length != 1) {
break;
}
current = bys[0];
}
return current;
}

@Nullable
private Object decorateWidget(Field field) {
Class<?> type = field.getType();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,34 +329,30 @@ private By returnMappedBy(By byDefault, By nativeAppBy) {
return new ContentMappedBy(contentMap);
}

@Override
public By buildBy() {
assertValidAnnotations();

By defaultBy = buildDefaultBy();
By mobileNativeBy = buildMobileNativeBy();

By buildMappedBy(@Nullable By defaultBy, @Nullable By mobileNativeBy) {
String idOrName = ((Field) annotatedElementContainer.getAnnotated()).getName();

if (defaultBy == null && mobileNativeBy == null) {
defaultBy = new ByIdOrName(((Field) annotatedElementContainer.getAnnotated()).getName());
mobileNativeBy = new By.ById(idOrName);
return returnMappedBy(defaultBy, mobileNativeBy);
return returnMappedBy(new ByIdOrName(idOrName), new By.ById(idOrName));
}

if (defaultBy == null) {
defaultBy = new ByIdOrName(((Field) annotatedElementContainer.getAnnotated()).getName());
return returnMappedBy(defaultBy, mobileNativeBy);
return returnMappedBy(new ByIdOrName(idOrName), mobileNativeBy);
}

if (mobileNativeBy == null) {
mobileNativeBy = defaultBy;
return returnMappedBy(defaultBy, mobileNativeBy);
return returnMappedBy(defaultBy, defaultBy);
}

return returnMappedBy(defaultBy, mobileNativeBy);
}

@Override
public By buildBy() {
assertValidAnnotations();
return buildMappedBy(buildDefaultBy(), buildMobileNativeBy());
}

private static class AnnotationComparator implements Comparator<Annotation> {

private static Method getPriorityMethod(Class<? extends Annotation> clazz) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,39 @@

import lombok.EqualsAndHashCode;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.openqa.selenium.By;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;

import java.util.List;
import java.util.Map;
import java.util.Objects;

import static io.appium.java_client.pagefactory.bys.ContentType.HTML_OR_DEFAULT;
import static io.appium.java_client.pagefactory.bys.ContentType.NATIVE_MOBILE_SPECIFIC;
import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType;
import static java.util.Objects.requireNonNull;

/** Locator which selects the underlying locator by the current content type. */
/**
* Locator which selects the underlying locator by the active {@link ContentType}.
*
* <p>When a content type is explicitly pinned via {@link #useContent(ContentType)},
* {@link #findElement(SearchContext)}, {@link #findElements(SearchContext)}, and
* {@link #toString()} use that pinned entry. When no content type has been set,
* {@link #findElement(SearchContext)} and {@link #findElements(SearchContext)}
* auto-detect the content type from the target {@link SearchContext} via
* {@link io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility#getCurrentContentType(SearchContext)}
* (resolving to {@link ContentType#NATIVE_MOBILE_SPECIFIC} for native mobile contexts and
* {@link ContentType#HTML_OR_DEFAULT} for web/browser contexts or plain {@link SearchContext}
* implementations that implement neither {@link io.appium.java_client.HasBrowserCheck} nor
* {@link io.appium.java_client.remote.SupportsContextSwitching}).
*/
@EqualsAndHashCode(callSuper = true)
public class ContentMappedBy extends By {
private final Map<ContentType, By> map;
private ContentType currentContent = NATIVE_MOBILE_SPECIFIC;
@Nullable
private ContentType currentContent;

/**
* Creates a new locator.
Expand All @@ -57,16 +75,33 @@ public By useContent(@NonNull ContentType type) {

@Override
public WebElement findElement(SearchContext context) {
return context.findElement(map.get(currentContent));
return context.findElement(map.get(resolveContentType(context)));
}

@Override
public List<WebElement> findElements(SearchContext context) {
return context.findElements(map.get(currentContent));
return context.findElements(map.get(resolveContentType(context)));
}

@Override
public String toString() {
return map.get(currentContent).toString();
if (currentContent != null) {
return map.get(currentContent).toString();
}
By defaultBy = map.get(HTML_OR_DEFAULT);
By nativeBy = map.get(NATIVE_MOBILE_SPECIFIC);
if (Objects.equals(defaultBy, nativeBy) && nativeBy != null) {
return nativeBy.toString();
}
return String.format(
"%s(%s=%s, %s=%s)",
getClass().getSimpleName(),
HTML_OR_DEFAULT, defaultBy,
NATIVE_MOBILE_SPECIFIC, nativeBy
);
}

private ContentType resolveContentType(SearchContext context) {
return currentContent != null ? currentContent : getCurrentContentType(context);
}
}
34 changes: 29 additions & 5 deletions src/main/java/io/appium/java_client/support/PageFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
*/
public final class PageFactory {

private static final ThreadLocal<Deque<Class<?>>> PAGE_CLASS_STACK =
private static final ThreadLocal<Deque<Object>> PAGE_OBJECT_STACK =
ThreadLocal.withInitial(ArrayDeque::new);

private PageFactory() {
Expand All @@ -46,7 +46,31 @@ private PageFactory() {
*/
@Nullable
public static Class<?> getCurrentPageClass() {
return PAGE_CLASS_STACK.get().peek();
Object current = PAGE_OBJECT_STACK.get().peek();
return current != null ? current.getClass() : null;
}

/**
* Checks whether the given {@code field} already has a non-null value on the page object
* currently being initialized by {@link #initElements(FieldDecorator, Object)} on the
* current thread.
*
* @param field the field to inspect
* @return {@code true} if the field can be inspected and already holds a non-null value
* on the active page object; {@code false} otherwise
*/
public static boolean isCurrentPageFieldInitialized(Field field) {
for (Object page : PAGE_OBJECT_STACK.get()) {
if (field.getDeclaringClass().isInstance(page)) {
try {
field.setAccessible(true);
return field.get(page) != null;
} catch (IllegalAccessException | RuntimeException e) {
return false;
}
}
}
return false;
}

/**
Expand All @@ -69,8 +93,8 @@ public static void initElements(ElementLocatorFactory factory, Object page) {
* @param page The object to decorate the fields of
*/
public static void initElements(FieldDecorator decorator, Object page) {
Deque<Class<?>> stack = PAGE_CLASS_STACK.get();
stack.push(page.getClass());
Deque<Object> stack = PAGE_OBJECT_STACK.get();
stack.push(page);
try {
Class<?> proxyIn = page.getClass();
while (proxyIn != Object.class) {
Expand All @@ -80,7 +104,7 @@ public static void initElements(FieldDecorator decorator, Object page) {
} finally {
stack.pop();
if (stack.isEmpty()) {
PAGE_CLASS_STACK.remove();
PAGE_OBJECT_STACK.remove();
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ public ByAll(By... bys) {
this.bys = bys;
}

/**
* Returns the locators searched by this composite locator.
*
* @return a copy of the underlying locator array
*/
public By[] getBys() {
return bys == null ? new By[0] : bys.clone();
}

@Override
public WebElement findElement(SearchContext context) {
for (By by : bys) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ public ByChained(By... bys) {
this.bys = bys;
}

/**
* Returns the locators that form this chain.
*
* @return a copy of the underlying locator array
*/
public By[] getBys() {
return bys == null ? new By[0] : bys.clone();
}

@Override
public WebElement findElement(SearchContext context) {
List<WebElement> elements = findElements(context);
Expand Down
Loading
Loading