Skip to content
Merged
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 @@ -19,13 +19,17 @@
import io.appium.java_client.pagefactory.bys.builder.AppiumByBuilder;
import io.appium.java_client.pagefactory.locator.CacheableElementLocatorFactory;
import io.appium.java_client.pagefactory.locator.CacheableLocator;
import io.appium.java_client.support.PageFactory;
import org.jspecify.annotations.Nullable;
import org.openqa.selenium.SearchContext;

import java.lang.ref.WeakReference;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.Duration;
import java.util.List;

import static io.appium.java_client.pagefactory.WithTimeout.DurationBuilder.build;
import static java.util.Optional.ofNullable;
Expand Down Expand Up @@ -82,13 +86,7 @@ public CacheableLocator createLocator(Field field) {
@Nullable
@Override
public CacheableLocator createLocator(AnnotatedElement annotatedElement) {
Duration customDuration;
if (annotatedElement.isAnnotationPresent(WithTimeout.class)) {
WithTimeout withTimeout = annotatedElement.getAnnotation(WithTimeout.class);
customDuration = build(withTimeout);
} else {
customDuration = duration;
}
Duration customDuration = resolveCustomDuration(annotatedElement);
builder.setAnnotated(annotatedElement);
try {
return ofNullable(builder.buildBy())
Expand All @@ -104,4 +102,77 @@ public CacheableLocator createLocator(AnnotatedElement annotatedElement) {
builder.setAnnotated(null);
}
}

private Duration resolveCustomDuration(AnnotatedElement annotatedElement) {
if (annotatedElement.isAnnotationPresent(WithTimeout.class)) {
return build(annotatedElement.getAnnotation(WithTimeout.class));
}

if (annotatedElement instanceof Field) {
Field field = (Field) annotatedElement;
Class<?> widgetClass = extractWidgetClass(field);
if (widgetClass != null) {
WithTimeout widgetTimeout = findClassTimeout(widgetClass);
if (widgetTimeout != null) {
return build(widgetTimeout);
}
}
Class<?> currentPageClass = PageFactory.getCurrentPageClass();
if (currentPageClass != null && field.getDeclaringClass().isAssignableFrom(currentPageClass)) {
WithTimeout pageTimeout = findClassTimeout(currentPageClass);
if (pageTimeout != null) {
return build(pageTimeout);
}
}
WithTimeout declaringTimeout = findClassTimeout(field.getDeclaringClass());
if (declaringTimeout != null) {
return build(declaringTimeout);
}
} else if (annotatedElement instanceof Class<?>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Javadoc says the annotation applies to a "page object/widget class", but a @WithTimeout on a Widget subclass has no effect on locating the widget field itself: createLocator(field) resolves against the page class. Only the widget's own inner fields honor it. Worth either handling the field type here or narrowing the Javadoc.

WithTimeout classTimeout = findClassTimeout((Class<?>) annotatedElement);
if (classTimeout != null) {
return build(classTimeout);
}
}

return duration;
}

@Nullable
private static WithTimeout findClassTimeout(@Nullable Class<?> clazz) {
for (Class<?> current = clazz; current != null; current = current.getEnclosingClass()) {
WithTimeout annotation = current.getAnnotation(WithTimeout.class);
if (annotation != null) {
return annotation;
}
}
return null;
}

@Nullable
private static Class<?> extractWidgetClass(Field field) {
Class<?> fieldType = field.getType();
if (Widget.class.isAssignableFrom(fieldType)) {
return fieldType;
}
if (!List.class.isAssignableFrom(fieldType)) {
return null;
}
Type genericType = field.getGenericType();
if (!(genericType instanceof ParameterizedType)) {
return null;
}
Type[] typeArguments = ((ParameterizedType) genericType).getActualTypeArguments();
if (typeArguments.length == 0) {
return null;
}
Type listType = typeArguments[0];
if (listType instanceof ParameterizedType) {
listType = ((ParameterizedType) listType).getRawType();
}
if (listType instanceof Class<?> && Widget.class.isAssignableFrom((Class<?>) listType)) {
return (Class<?>) listType;
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@
package io.appium.java_client.pagefactory;

import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.Duration;
import java.time.temporal.ChronoUnit;

/**
* This annotation is used when some element waits for time
* that differs from defined by default.
* This annotation is used when some element or all elements in a page object/widget class
* wait for time that differs from the default duration.
*
* <p>Can be placed on a specific field or on a page object / {@link Widget} class (inherited by subclasses).
* Precedence order is: field-level {@code @WithTimeout} &gt; widget class {@code @WithTimeout}
* &gt; page/declaring class {@code @WithTimeout} (including superclasses and enclosing classes)
* &gt; the default {@link Duration} configured on {@link AppiumFieldDecorator}.
*/
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD})
@Inherited
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE})
public @interface WithTimeout {
/**
* Desired waiting duration.
Expand Down
35 changes: 31 additions & 4 deletions src/main/java/io/appium/java_client/support/PageFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,36 @@
import io.appium.java_client.support.pagefactory.DefaultFieldDecorator;
import io.appium.java_client.support.pagefactory.ElementLocatorFactory;
import io.appium.java_client.support.pagefactory.FieldDecorator;
import org.jspecify.annotations.Nullable;

import java.lang.reflect.Field;
import java.util.ArrayDeque;
import java.util.Deque;

/**
* Factory class to make using Page Objects simpler and easier.
* Adapted from Selenium's {@code org.openqa.selenium.support.PageFactory} (Apache License 2.0).
*/
public final class PageFactory {

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

private PageFactory() {
}

/**
* Returns the runtime class of the page object currently being initialized by
* {@link #initElements(FieldDecorator, Object)} on the current thread, or {@code null}
* if no page initialization is active.
*
* @return the current page class or {@code null}
*/
@Nullable
public static Class<?> getCurrentPageClass() {
return PAGE_CLASS_STACK.get().peek();
}

/**
* Similar to the other "initElements" methods, but takes an {@link ElementLocatorFactory} which
* is used for providing the mechanism for finding elements. If the ElementLocatorFactory returns
Expand All @@ -51,10 +69,19 @@ 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) {
Class<?> proxyIn = page.getClass();
while (proxyIn != Object.class) {
proxyFields(decorator, page, proxyIn);
proxyIn = proxyIn.getSuperclass();
Deque<Class<?>> stack = PAGE_CLASS_STACK.get();
stack.push(page.getClass());
try {
Class<?> proxyIn = page.getClass();
while (proxyIn != Object.class) {
proxyFields(decorator, page, proxyIn);
proxyIn = proxyIn.getSuperclass();
}
} finally {
stack.pop();
if (stack.isEmpty()) {
PAGE_CLASS_STACK.remove();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package io.appium.java_client.pagefactory_tests;

import io.appium.java_client.pagefactory.AppiumFieldDecorator;
import io.appium.java_client.pagefactory.Widget;
import io.appium.java_client.pagefactory.WithTimeout;
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.support.FindAll;
import io.appium.java_client.support.FindBy;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -41,7 +43,7 @@ public class TimeoutTest {

private static final Duration DEFAULT_TIMEOUT = ofMillis(300);
private static final long CLOCK_SLACK_MS = 50;
private static final long ACCEPTABLE_OVERRUN_MS = 1500;
private static final long ACCEPTABLE_OVERRUN_MS = 3000;

@FindAll({
@FindBy(className = "ClassWhichDoesNotExist"),
Expand All @@ -63,12 +65,24 @@ private static void assertWaitsFor(Duration expected, Runnable runnable) {
lessThanOrEqualTo(expected.toMillis() + ACCEPTABLE_OVERRUN_MS));
}

private WebDriver driver;

@BeforeEach public void setUp() {
WebDriver driver = new AbstractStubWebDriver() {
driver = new AbstractStubWebDriver() {
@Override
public List<WebElement> findElements(By by) {
return List.of();
}

@Override
public StubWebElement findElement(By by) {
return new StubWebElement(this, by) {
@Override
public List<WebElement> findElements(By childBy) {
return List.of();
}
};
}
};
initElements(new AppiumFieldDecorator(driver, DEFAULT_TIMEOUT), this);
}
Expand All @@ -77,4 +91,89 @@ public List<WebElement> findElements(By by) {
assertWaitsFor(DEFAULT_TIMEOUT, () -> stubElements.size());
assertWaitsFor(ofMillis(800), () -> stubElements2.size());
}

@Test public void withClassLevelTimeOutTest() {
ClassWithTimeoutPage page = new ClassWithTimeoutPage();
initElements(new AppiumFieldDecorator(driver, DEFAULT_TIMEOUT), page);

assertWaitsFor(ofMillis(600), () -> page.classTimeoutElements.size());
assertWaitsFor(ofMillis(1100), () -> page.fieldOverrideElements.size());
}

@Test public void withInheritedClassLevelTimeOutTest() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests assert on wall-clock time with a tight overrun bound (ACCEPTABLE_OVERRUN_MS), so they may be flaky on slow CI. Coverage is also missing for a field declared in a superclass of an annotated subclass and for the Widget path, which is why the gaps noted above go unnoticed.

InheritedClassTimeoutPage page = new InheritedClassTimeoutPage();
initElements(new AppiumFieldDecorator(driver, DEFAULT_TIMEOUT), page);

assertWaitsFor(ofMillis(600), () -> page.subclassElements.size());
}

@Test public void withAnnotatedSubclassSuperclassFieldTimeOutTest() {
AnnotatedSubclassPage page = new AnnotatedSubclassPage();
initElements(new AppiumFieldDecorator(driver, DEFAULT_TIMEOUT), page);

assertWaitsFor(ofMillis(600), () -> page.baseElements.size());
}

@Test public void withWidgetClassLevelTimeOutTest() {
PageWithTimeoutWidget page = new PageWithTimeoutWidget();
initElements(new AppiumFieldDecorator(driver, DEFAULT_TIMEOUT), page);

assertWaitsFor(ofMillis(600), () -> page.widgetList.size());
assertWaitsFor(ofMillis(600), () -> page.widget.innerElementsCount());
assertWaitsFor(ofMillis(1100), () -> page.widget.fieldOverrideInnerElementsCount());
}

@WithTimeout(time = 600, chronoUnit = MILLIS)
private static class ClassWithTimeoutPage {
@FindBy(className = "ClassWhichDoesNotExist")
private List<WebElement> classTimeoutElements;

@WithTimeout(time = 1100, chronoUnit = MILLIS)
@FindBy(className = "ClassWhichDoesNotExist")
private List<WebElement> fieldOverrideElements;
}

private static class InheritedClassTimeoutPage extends ClassWithTimeoutPage {
@FindBy(className = "ClassWhichDoesNotExist")
private List<WebElement> subclassElements;
}

private static class UnannotatedBasePage {
@FindBy(className = "ClassWhichDoesNotExist")
protected List<WebElement> baseElements;
}

@WithTimeout(time = 600, chronoUnit = MILLIS)
private static class AnnotatedSubclassPage extends UnannotatedBasePage {
}

@WithTimeout(time = 600, chronoUnit = MILLIS)
public static class CustomTimeoutWidget extends Widget {
@FindBy(className = "ClassWhichDoesNotExist")
private List<WebElement> innerElements;

@WithTimeout(time = 1100, chronoUnit = MILLIS)
@FindBy(className = "ClassWhichDoesNotExist")
private List<WebElement> fieldOverrideInnerElements;

protected CustomTimeoutWidget(WebElement element) {
super(element);
}

public int innerElementsCount() {
return innerElements.size();
}

public int fieldOverrideInnerElementsCount() {
return fieldOverrideInnerElements.size();
}
}

private static class PageWithTimeoutWidget {
@FindBy(className = "WidgetRoot")
private CustomTimeoutWidget widget;

@FindBy(className = "MissingWidgetList")
private List<CustomTimeoutWidget> widgetList;
}
}
Loading