elements = new ArrayList<>();
+ elements.addAll(idFinder.findElements(context));
+ elements.addAll(nameFinder.findElements(context));
+ return elements;
+ }
+
+ @Override
+ public String toString() {
+ return "by id or name \"" + idOrName + '"';
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/CacheLookup.java b/src/main/java/io/appium/java_client/support/CacheLookup.java
new file mode 100644
index 000000000..fc3a9afe0
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/CacheLookup.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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;
+
+/**
+ * Marker annotation to be applied to WebElements to indicate that it never changes (that is, that
+ * the same instance will always be used).
+ * Adapted from Selenium's {@code org.openqa.selenium.support.CacheLookup} (Apache License 2.0).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CacheLookup {
+}
diff --git a/src/main/java/io/appium/java_client/support/FindAll.java b/src/main/java/io/appium/java_client/support/FindAll.java
new file mode 100644
index 000000000..68784c486
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/FindAll.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import io.appium.java_client.support.pagefactory.ByAll;
+import org.openqa.selenium.By;
+
+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;
+
+/**
+ * Used to mark a field on a Page Object to indicate that lookup should use a series of
+ * {@link FindBy} tags. It will then search for all elements that match any of the FindBy
+ * criteria. Note that elements are not guaranteed to be in document order.
+ *
+ * Eg:
+ *
+ *
+ * @FindAll({@FindBy(how = How.ID, using = "foo"),
+ * @FindBy(className = "bar")})
+ *
+ *
+ * Adapted from Selenium's {@code org.openqa.selenium.support.FindAll} (Apache License 2.0).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.TYPE})
+public @interface FindAll {
+ /**
+ * The locators to search with.
+ *
+ * @return the {@link FindBy} annotations to combine
+ */
+ FindBy[] value();
+
+ class FindByBuilder extends AbstractFindByBuilder {
+ @Override
+ public By buildIt(FindAll findBys, Field field) {
+ assertValidFindAll(findBys);
+
+ FindBy[] findByArray = findBys.value();
+ By[] byArray = new By[findByArray.length];
+ for (int i = 0; i < findByArray.length; i++) {
+ byArray[i] = buildByFromFindBy(findByArray[i]);
+ }
+
+ return new ByAll(byArray);
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/FindBy.java b/src/main/java/io/appium/java_client/support/FindBy.java
new file mode 100644
index 000000000..20762e4ec
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/FindBy.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import org.openqa.selenium.By;
+
+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;
+
+/**
+ * Used to mark a field on a Page Object to indicate an alternative mechanism for locating the
+ * element or a list of elements. Used in conjunction with {@link PageFactory} this allows users to
+ * quickly and easily create PageObjects.
+ *
+ * You can either use this annotation by specifying both "how" and "using" or by specifying one
+ * of the location strategies (eg: "id") with an appropriate value to use. Both options will
+ * delegate down to the matching {@link By} methods in By class.
+ *
+ *
For example, these two annotations point to the same element:
+ *
+ *
+ * @FindBy(id = "foobar") WebElement foobar;
+ * @FindBy(how = How.ID, using = "foobar") WebElement foobar;
+ *
+ *
+ * Adapted from Selenium's {@code org.openqa.selenium.support.FindBy} (Apache License 2.0).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.TYPE})
+public @interface FindBy {
+ /**
+ * The location strategy to use together with {@link #using()}.
+ *
+ * @return the configured value
+ */
+ How how() default How.UNSET;
+
+ /**
+ * The value of the strategy given in {@link #how()}.
+ *
+ * @return the configured value
+ */
+ String using() default "";
+
+ /**
+ * The element id.
+ *
+ * @return the configured value
+ */
+ String id() default "";
+
+ /**
+ * The element name.
+ *
+ * @return the configured value
+ */
+ String name() default "";
+
+ /**
+ * The element class name.
+ *
+ * @return the configured value
+ */
+ String className() default "";
+
+ /**
+ * The CSS selector.
+ *
+ * @return the configured value
+ */
+ String css() default "";
+
+ /**
+ * The element tag name.
+ *
+ * @return the configured value
+ */
+ String tagName() default "";
+
+ /**
+ * The exact link text.
+ *
+ * @return the configured value
+ */
+ String linkText() default "";
+
+ /**
+ * A part of the link text.
+ *
+ * @return the configured value
+ */
+ String partialLinkText() default "";
+
+ /**
+ * The XPath expression.
+ *
+ * @return the configured value
+ */
+ String xpath() default "";
+
+ class FindByBuilder extends AbstractFindByBuilder {
+ @Override
+ public By buildIt(FindBy findBy, Field field) {
+ assertValidFindBy(findBy);
+
+ By ans = buildByFromShortFindBy(findBy);
+ if (ans == null) {
+ ans = buildByFromLongFindBy(findBy);
+ }
+
+ return ans;
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/FindBys.java b/src/main/java/io/appium/java_client/support/FindBys.java
new file mode 100644
index 000000000..1a18c47f6
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/FindBys.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import io.appium.java_client.support.pagefactory.ByChained;
+import org.openqa.selenium.By;
+
+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;
+
+/**
+ * Used to mark a field on a Page Object to indicate that lookup should use a series of
+ * {@link FindBy} tags in a chain as described in {@link ByChained}.
+ *
+ * Eg:
+ *
+ *
+ * @FindBys({@FindBy(id = "foo"),
+ * @FindBy(className = "bar")})
+ *
+ *
+ * Adapted from Selenium's {@code org.openqa.selenium.support.FindBys} (Apache License 2.0).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.TYPE})
+public @interface FindBys {
+ /**
+ * The locators to chain.
+ *
+ * @return the {@link FindBy} annotations to chain
+ */
+ FindBy[] value();
+
+ class FindByBuilder extends AbstractFindByBuilder {
+ @Override
+ public By buildIt(FindBys findBys, Field field) {
+ assertValidFindBys(findBys);
+
+ FindBy[] findByArray = findBys.value();
+ By[] byArray = new By[findByArray.length];
+ for (int i = 0; i < findByArray.length; i++) {
+ byArray[i] = buildByFromFindBy(findByArray[i]);
+ }
+
+ return new ByChained(byArray);
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/How.java b/src/main/java/io/appium/java_client/support/How.java
new file mode 100644
index 000000000..f563e5579
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/How.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import org.openqa.selenium.By;
+
+/**
+ * Location strategies for {@link FindBy#how()}.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.How} (Apache License 2.0).
+ */
+public enum How {
+ CLASS_NAME {
+ @Override
+ public By buildBy(String value) {
+ return By.className(value);
+ }
+ },
+ CSS {
+ @Override
+ public By buildBy(String value) {
+ return By.cssSelector(value);
+ }
+ },
+ ID {
+ @Override
+ public By buildBy(String value) {
+ return By.id(value);
+ }
+ },
+ ID_OR_NAME {
+ @Override
+ public By buildBy(String value) {
+ return new ByIdOrName(value);
+ }
+ },
+ LINK_TEXT {
+ @Override
+ public By buildBy(String value) {
+ return By.linkText(value);
+ }
+ },
+ NAME {
+ @Override
+ public By buildBy(String value) {
+ return By.name(value);
+ }
+ },
+ PARTIAL_LINK_TEXT {
+ @Override
+ public By buildBy(String value) {
+ return By.partialLinkText(value);
+ }
+ },
+ TAG_NAME {
+ @Override
+ public By buildBy(String value) {
+ return By.tagName(value);
+ }
+ },
+ XPATH {
+ @Override
+ public By buildBy(String value) {
+ return By.xpath(value);
+ }
+ },
+ UNSET {
+ @Override
+ public By buildBy(String value) {
+ return ID.buildBy(value);
+ }
+ };
+
+ public abstract By buildBy(String value);
+}
diff --git a/src/main/java/io/appium/java_client/support/PageFactory.java b/src/main/java/io/appium/java_client/support/PageFactory.java
new file mode 100644
index 000000000..79d6f6639
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/PageFactory.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+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 java.lang.reflect.Field;
+
+/**
+ * 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 PageFactory() {
+ }
+
+ /**
+ * 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
+ * null then the field won't be decorated.
+ *
+ * @param factory The factory to use
+ * @param page The object to decorate the fields of
+ */
+ public static void initElements(ElementLocatorFactory factory, Object page) {
+ initElements(new DefaultFieldDecorator(factory), page);
+ }
+
+ /**
+ * Takes a {@link FieldDecorator} which is used for decorating each of the fields of the page,
+ * including the ones declared in its superclasses.
+ *
+ * @param decorator the decorator to use
+ * @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();
+ }
+ }
+
+ private static void proxyFields(FieldDecorator decorator, Object page, Class> proxyIn) {
+ Field[] fields = proxyIn.getDeclaredFields();
+ for (Field field : fields) {
+ Object value = decorator.decorate(page.getClass().getClassLoader(), field);
+ if (value != null) {
+ try {
+ field.setAccessible(true);
+ field.set(page, value);
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/functions/ExpectedCondition.java b/src/main/java/io/appium/java_client/support/package-info.java
similarity index 56%
rename from src/main/java/io/appium/java_client/functions/ExpectedCondition.java
rename to src/main/java/io/appium/java_client/support/package-info.java
index 926577c53..43440cd5b 100644
--- a/src/main/java/io/appium/java_client/functions/ExpectedCondition.java
+++ b/src/main/java/io/appium/java_client/support/package-info.java
@@ -14,19 +14,9 @@
* limitations under the License.
*/
-package io.appium.java_client.functions;
-
-import org.openqa.selenium.WebDriver;
-
/**
- * This is extended version of {@link org.openqa.selenium.support.ui.ExpectedCondition}. It is combined
- * with {@link java.util.function.Function}.
- *
- * @param The return type
- * @deprecated Use {@link org.openqa.selenium.support.ui.ExpectedCondition} instead
+ * The page factory and wait engine, adapted from Selenium's {@code org.openqa.selenium.support}
+ * (Apache License 2.0). This package tree must stay self-contained: it may only depend on
+ * selenium-api, jspecify and the JDK, which is enforced by the checkstyle import control rules.
*/
-@Deprecated
-@FunctionalInterface
-public interface ExpectedCondition extends org.openqa.selenium.support.ui.ExpectedCondition,
- AppiumFunction {
-}
+package io.appium.java_client.support;
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/AbstractAnnotations.java b/src/main/java/io/appium/java_client/support/pagefactory/AbstractAnnotations.java
new file mode 100644
index 000000000..507385f46
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/AbstractAnnotations.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import org.openqa.selenium.By;
+
+/**
+ * Abstract class to work with fields in Page Objects. Provides methods to process
+ * {@code @FindBy}, {@code @FindBys} and {@code @FindAll} annotations.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.AbstractAnnotations}
+ * (Apache License 2.0).
+ */
+public abstract class AbstractAnnotations {
+ /**
+ * Defines how to transform given object (field, class, etc.) into {@link By}
+ * class used by webdriver to locate elements.
+ *
+ * @return By object
+ */
+ public abstract By buildBy();
+
+ /**
+ * Defines whether given element should be returned from cache on further calls.
+ *
+ * @return boolean if lookup cached
+ */
+ public abstract boolean isLookupCached();
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/ByAll.java b/src/main/java/io/appium/java_client/support/pagefactory/ByAll.java
new file mode 100644
index 000000000..cb6570d9b
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/ByAll.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.SearchContext;
+import org.openqa.selenium.WebElement;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Mechanism used to locate elements within a document using a series of lookups. This class will
+ * find all elements that match any of the locators in sequence, e.g.
+ * {@code driver.findElements(new ByAll(by1, by2))} will find all elements that match
+ * by1 and then all elements that match by2.
+ * This means that the list of elements returned may not be in document order.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.ByAll}
+ * (Apache License 2.0).
+ */
+public class ByAll extends By implements Serializable {
+ private static final long serialVersionUID = 4573668832699497306L;
+
+ private final By[] bys;
+
+ public ByAll(By... bys) {
+ this.bys = bys;
+ }
+
+ @Override
+ public WebElement findElement(SearchContext context) {
+ for (By by : bys) {
+ List elements = context.findElements(by);
+ if (!elements.isEmpty()) {
+ return elements.get(0);
+ }
+ }
+ throw new NoSuchElementException("Cannot locate an element using " + this);
+ }
+
+ @Override
+ public List findElements(SearchContext context) {
+ List elems = new ArrayList<>();
+ for (By by : bys) {
+ elems.addAll(context.findElements(by));
+ }
+ return elems;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder stringBuilder = new StringBuilder("By.all(");
+ stringBuilder.append("{");
+
+ boolean first = true;
+ for (By by : bys) {
+ stringBuilder.append(first ? "" : ",").append(by);
+ first = false;
+ }
+ stringBuilder.append("})");
+ return stringBuilder.toString();
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/ByChained.java b/src/main/java/io/appium/java_client/support/pagefactory/ByChained.java
new file mode 100644
index 000000000..38e96df20
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/ByChained.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.SearchContext;
+import org.openqa.selenium.WebElement;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Mechanism used to locate elements within a document using a series of other lookups. This class
+ * will find all elements that match each of the locators in sequence, e.g.
+ * {@code driver.findElements(new ByChained(by1, by2))} will find all elements that match
+ * by2 and appear under an element that matches by1.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.ByChained}
+ * (Apache License 2.0).
+ */
+public class ByChained extends By implements Serializable {
+ private static final long serialVersionUID = 1563769051170172451L;
+
+ private final By[] bys;
+
+ public ByChained(By... bys) {
+ this.bys = bys;
+ }
+
+ @Override
+ public WebElement findElement(SearchContext context) {
+ List elements = findElements(context);
+ if (elements.isEmpty()) {
+ throw new NoSuchElementException("Cannot locate an element using " + this);
+ }
+ return elements.get(0);
+ }
+
+ @Override
+ public List findElements(SearchContext context) {
+ if (bys.length == 0) {
+ return new ArrayList<>();
+ }
+
+ List elems = bys[0].findElements(context);
+ for (int i = 1; i < bys.length; i++) {
+ if (elems.isEmpty()) {
+ break; // if any one of the bys finds no elements, then return no elements
+ }
+ final By by = bys[i];
+ elems = elems.stream()
+ .map(elem -> elem.findElements(by))
+ .flatMap(List::stream)
+ .collect(Collectors.toList());
+ }
+ return elems;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "By.chained({%s})", Stream.of(bys).map(By::toString).collect(Collectors.joining(",")));
+ }
+}
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
new file mode 100644
index 000000000..7ddee33eb
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/DefaultFieldDecorator.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.internal.LocatingElementHandler;
+import io.appium.java_client.support.pagefactory.internal.LocatingElementListHandler;
+import org.jspecify.annotations.Nullable;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.WrapsElement;
+import org.openqa.selenium.interactions.Locatable;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Proxy;
+import java.lang.reflect.Type;
+import java.util.List;
+
+/**
+ * Default decorator for use with PageFactory. Will decorate 1) all the WebElement fields and 2)
+ * {@code List} fields that have {@code @FindBy}, {@code @FindBys}, or {@code @FindAll}
+ * annotation with a proxy that locates the elements using the passed in ElementLocatorFactory.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.DefaultFieldDecorator}
+ * (Apache License 2.0).
+ */
+public class DefaultFieldDecorator implements FieldDecorator {
+
+ protected ElementLocatorFactory factory;
+
+ public DefaultFieldDecorator(ElementLocatorFactory factory) {
+ this.factory = factory;
+ }
+
+ @Nullable
+ @Override
+ public Object decorate(ClassLoader loader, Field field) {
+ if (!(WebElement.class.isAssignableFrom(field.getType()) || isDecoratableList(field))) {
+ return null;
+ }
+
+ ElementLocator locator = factory.createLocator(field);
+ if (locator == null) {
+ return null;
+ }
+
+ if (WebElement.class.isAssignableFrom(field.getType())) {
+ return proxyForLocator(loader, locator);
+ } else if (List.class.isAssignableFrom(field.getType())) {
+ return proxyForListLocator(loader, locator);
+ } else {
+ return null;
+ }
+ }
+
+ protected boolean isDecoratableList(Field field) {
+ if (!List.class.isAssignableFrom(field.getType())) {
+ return false;
+ }
+
+ // Type erasure in Java isn't complete. Attempt to discover the generic type of the list.
+ Type genericType = field.getGenericType();
+ if (!(genericType instanceof ParameterizedType)) {
+ return false;
+ }
+
+ Type listType = ((ParameterizedType) genericType).getActualTypeArguments()[0];
+
+ if (!WebElement.class.equals(listType)) {
+ return false;
+ }
+
+ return field.getAnnotation(FindBy.class) != null
+ || field.getAnnotation(FindBys.class) != null
+ || field.getAnnotation(FindAll.class) != null;
+ }
+
+ protected WebElement proxyForLocator(ClassLoader loader, ElementLocator locator) {
+ InvocationHandler handler = new LocatingElementHandler(locator);
+ return (WebElement) Proxy.newProxyInstance(
+ loader, new Class[]{WebElement.class, WrapsElement.class, Locatable.class}, handler);
+ }
+
+ @SuppressWarnings("unchecked")
+ protected List proxyForListLocator(ClassLoader loader, ElementLocator locator) {
+ InvocationHandler handler = new LocatingElementListHandler(locator);
+ return (List) Proxy.newProxyInstance(loader, new Class[]{List.class}, handler);
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/ElementLocator.java b/src/main/java/io/appium/java_client/support/pagefactory/ElementLocator.java
new file mode 100644
index 000000000..2fb2409da
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/ElementLocator.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import org.openqa.selenium.WebElement;
+
+import java.util.List;
+
+/**
+ * The interface used by the {@code PageFactory} to locate elements.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.ElementLocator}
+ * (Apache License 2.0).
+ */
+public interface ElementLocator {
+ /**
+ * Find the element.
+ *
+ * @return The WebElement that matches the selector.
+ */
+ WebElement findElement();
+
+ /**
+ * Find the elements.
+ *
+ * @return A list of all {@link WebElement}s that match the selector.
+ */
+ List findElements();
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/ElementLocatorFactory.java b/src/main/java/io/appium/java_client/support/pagefactory/ElementLocatorFactory.java
new file mode 100644
index 000000000..4f803f8c8
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/ElementLocatorFactory.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import java.lang.reflect.Field;
+
+/**
+ * A factory for producing {@link ElementLocator}s. It is expected that a new ElementLocator will be
+ * returned per call.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.ElementLocatorFactory}
+ * (Apache License 2.0).
+ */
+public interface ElementLocatorFactory {
+ /**
+ * When a field on a class needs to be decorated with an {@link ElementLocator} this method will
+ * be called.
+ *
+ * @param field the field
+ * @return An ElementLocator object.
+ */
+ ElementLocator createLocator(Field field);
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/FieldDecorator.java b/src/main/java/io/appium/java_client/support/pagefactory/FieldDecorator.java
new file mode 100644
index 000000000..f102db99a
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/FieldDecorator.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory;
+
+import org.jspecify.annotations.Nullable;
+
+import java.lang.reflect.Field;
+
+/**
+ * Allows the PageFactory to decorate fields.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.pagefactory.FieldDecorator}
+ * (Apache License 2.0).
+ */
+public interface FieldDecorator {
+ /**
+ * This method is called by PageFactory on all fields to decide how to decorate the field.
+ *
+ * @param loader The class loader that was used for the page object
+ * @param field The field that may be decorated.
+ * @return Value to decorate the field with or null if it shouldn't be decorated. If non-null,
+ * must be assignable to the field.
+ */
+ @Nullable Object decorate(ClassLoader loader, Field field);
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementHandler.java b/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementHandler.java
new file mode 100644
index 000000000..f9bb1e6d9
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementHandler.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory.internal;
+
+import io.appium.java_client.support.pagefactory.ElementLocator;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.WebElement;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Adapted from Selenium's
+ * {@code org.openqa.selenium.support.pagefactory.internal.LocatingElementHandler}
+ * (Apache License 2.0).
+ */
+public class LocatingElementHandler implements InvocationHandler {
+ private final ElementLocator locator;
+
+ public LocatingElementHandler(ElementLocator locator) {
+ this.locator = locator;
+ }
+
+ @Override
+ public Object invoke(Object object, Method method, Object[] objects) throws Throwable {
+ WebElement element;
+ try {
+ element = locator.findElement();
+ } catch (NoSuchElementException e) {
+ if ("toString".equals(method.getName())) {
+ return "Proxy element for: " + locator;
+ }
+ throw e;
+ }
+
+ if ("getWrappedElement".equals(method.getName())) {
+ return element;
+ }
+
+ try {
+ return method.invoke(element, objects);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementListHandler.java b/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementListHandler.java
new file mode 100644
index 000000000..af78d3d7d
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/pagefactory/internal/LocatingElementListHandler.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.pagefactory.internal;
+
+import io.appium.java_client.support.pagefactory.ElementLocator;
+import org.openqa.selenium.WebElement;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+
+/**
+ * Adapted from Selenium's
+ * {@code org.openqa.selenium.support.pagefactory.internal.LocatingElementListHandler}
+ * (Apache License 2.0).
+ */
+public class LocatingElementListHandler implements InvocationHandler {
+ private final ElementLocator locator;
+
+ public LocatingElementListHandler(ElementLocator locator) {
+ this.locator = locator;
+ }
+
+ @Override
+ public Object invoke(Object object, Method method, Object[] objects) throws Throwable {
+ List elements = locator.findElements();
+
+ try {
+ return method.invoke(elements, objects);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/ui/FluentWait.java b/src/main/java/io/appium/java_client/support/ui/FluentWait.java
new file mode 100644
index 000000000..2ee9e6ed6
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/ui/FluentWait.java
@@ -0,0 +1,249 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.ui;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+import org.openqa.selenium.TimeoutException;
+import org.openqa.selenium.WebDriverException;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static java.math.BigDecimal.ONE;
+import static java.math.RoundingMode.HALF_UP;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * An implementation of the {@link Wait} interface that may have its timeout and polling interval
+ * configured on the fly. Each FluentWait instance defines the maximum amount of time to wait for a
+ * condition, as well as the frequency with which to check the condition. Furthermore, the user may
+ * configure the wait to ignore specific types of exceptions whilst waiting.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.ui.FluentWait} (Apache License 2.0).
+ *
+ * @param The input type for each condition used with this instance.
+ */
+public class FluentWait implements Wait {
+ protected static final long DEFAULT_SLEEP_TIMEOUT = 500;
+
+ private static final Duration DEFAULT_WAIT_DURATION = Duration.ofMillis(DEFAULT_SLEEP_TIMEOUT);
+
+ protected final T input;
+ protected final Clock clock;
+ protected final Sleeper sleeper;
+
+ protected Duration timeout = DEFAULT_WAIT_DURATION;
+ protected Duration interval = DEFAULT_WAIT_DURATION;
+ protected Supplier<@Nullable String> messageSupplier = () -> null;
+
+ protected final List> ignoredExceptions = new ArrayList<>();
+
+ /**
+ * Creates a wait that uses the system clock and {@link Sleeper#SYSTEM_SLEEPER}.
+ *
+ * @param input The input value to pass to the evaluated conditions.
+ */
+ public FluentWait(T input) {
+ this(input, Clock.systemDefaultZone(), Sleeper.SYSTEM_SLEEPER);
+ }
+
+ /**
+ * Creates a wait with a custom clock and sleeper, mainly useful for testing.
+ *
+ * @param input The input value to pass to the evaluated conditions.
+ * @param clock The clock to use when measuring the timeout.
+ * @param sleeper Used to put the thread to sleep between evaluation loops.
+ */
+ public FluentWait(T input, Clock clock, Sleeper sleeper) {
+ this.input = input;
+ this.clock = requireNonNull(clock, "Clock must not be null");
+ this.sleeper = requireNonNull(sleeper, "Sleeper must not be null");
+ }
+
+ /**
+ * Sets how long to wait for the evaluated condition to be true. The default timeout is
+ * {@link #DEFAULT_SLEEP_TIMEOUT}.
+ *
+ * @param timeout The timeout duration.
+ * @return A self reference.
+ */
+ public FluentWait withTimeout(Duration timeout) {
+ this.timeout = timeout;
+ return this;
+ }
+
+ /**
+ * Sets the message to be appended to the {@link TimeoutException} on timeout.
+ *
+ * @param message The failing message.
+ * @return A self reference.
+ */
+ public FluentWait withMessage(final String message) {
+ requireNonNull(message, "Message must not be null");
+ this.messageSupplier = () -> message;
+ return this;
+ }
+
+ /**
+ * Sets the supplier of the message to be appended to the {@link TimeoutException} on timeout.
+ *
+ * @param messageSupplier The supplier of the failing message.
+ * @return A self reference.
+ */
+ public FluentWait withMessage(Supplier messageSupplier) {
+ this.messageSupplier = requireNonNull(messageSupplier, "Message supplier must not be null");
+ return this;
+ }
+
+ /**
+ * Sets how often the condition should be evaluated. The default interval is
+ * {@link #DEFAULT_SLEEP_TIMEOUT}.
+ *
+ * @param interval The interval duration.
+ * @return A self reference.
+ */
+ public FluentWait pollingEvery(Duration interval) {
+ this.interval = interval;
+ return this;
+ }
+
+ /**
+ * Configures this instance to ignore specific types of exceptions while waiting for a condition.
+ *
+ * @param types The types of exceptions to ignore.
+ * @param an exception that extends Throwable
+ * @return A self reference.
+ */
+ public FluentWait ignoreAll(Collection> types) {
+ ignoredExceptions.addAll(types);
+ return this;
+ }
+
+ /**
+ * Configures this instance to ignore a specific type of exception while waiting for a condition.
+ *
+ * @param exceptionType exception to ignore
+ * @return a self reference
+ */
+ public FluentWait ignoring(Class extends Throwable> exceptionType) {
+ return this.ignoreAll(List.of(exceptionType));
+ }
+
+ /**
+ * Configures this instance to ignore two types of exceptions while waiting for a condition.
+ *
+ * @param firstType exception to ignore
+ * @param secondType another exception to ignore
+ * @return a self reference
+ */
+ public FluentWait ignoring(
+ Class extends Throwable> firstType, Class extends Throwable> secondType) {
+ return this.ignoreAll(List.of(firstType, secondType));
+ }
+
+ /**
+ * Repeatedly applies this instance's input value to the given function until one of the following
+ * occurs: the function returns neither null nor false, the function throws an unignored exception,
+ * or the timeout expires.
+ *
+ * @param isTrue the parameter to pass to the function
+ * @param The function's expected return type.
+ * @return The function's return value if the function returned something different from null or
+ * false before the timeout expired.
+ * @throws TimeoutException If the timeout expires.
+ */
+ @Override
+ public @NonNull V until(Function super T, ? extends V> isTrue) {
+ Instant end = clock.instant().plus(timeout);
+
+ Throwable lastException;
+ while (true) {
+ try {
+ V value = isTrue.apply(input);
+ if (value != null && (Boolean.class != value.getClass() || Boolean.TRUE.equals(value))) {
+ return value;
+ }
+
+ // The last exception is not the cause of a timeout triggered by a false or null value
+ lastException = null;
+ } catch (Throwable e) {
+ lastException = propagateIfNotIgnored(e);
+ }
+
+ // Checked after the evaluation so that conditions with a zero timeout can succeed
+ if (end.isBefore(clock.instant())) {
+ String message = messageSupplier.get();
+
+ String timeoutMessage = String.format(
+ "Expected condition failed: %s%n(tried for %s with %d milliseconds interval)",
+ message == null ? "waiting for " + isTrue : message,
+ formatTimeout(timeout),
+ interval.toMillis());
+ throw timeoutException(timeoutMessage, lastException);
+ }
+
+ try {
+ sleeper.sleep(interval);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new WebDriverException(e);
+ }
+ }
+ }
+
+ static String formatTimeout(Duration timeout) {
+ BigDecimal seconds = BigDecimal.valueOf(timeout.toMillis())
+ .divide(BigDecimal.valueOf(1000), 3, HALF_UP);
+ String value = seconds.stripTrailingZeros().toPlainString();
+ boolean singular = seconds.compareTo(ONE) == 0;
+ return value + (singular ? " second" : " seconds");
+ }
+
+ private Throwable propagateIfNotIgnored(Throwable e) {
+ for (Class extends Throwable> ignoredException : ignoredExceptions) {
+ if (ignoredException.isInstance(e)) {
+ return e;
+ }
+ }
+ if (e instanceof Error) {
+ throw (Error) e;
+ }
+ if (e instanceof RuntimeException) {
+ throw (RuntimeException) e;
+ }
+ throw new RuntimeException(e);
+ }
+
+ /**
+ * Throws a timeout exception. This method may be overridden to throw an exception that is
+ * idiomatic for a particular test infrastructure.
+ *
+ * @param message The timeout message.
+ * @param lastException The last exception that was ignored while waiting, if any.
+ * @return nothing will ever be returned; this return type is only specified as a convenience.
+ */
+ protected RuntimeException timeoutException(String message, @Nullable Throwable lastException) {
+ throw new TimeoutException(message, lastException);
+ }
+}
diff --git a/src/main/java/io/appium/java_client/support/ui/Sleeper.java b/src/main/java/io/appium/java_client/support/ui/Sleeper.java
new file mode 100644
index 000000000..80ced1c8a
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/ui/Sleeper.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.ui;
+
+import java.time.Duration;
+
+/**
+ * Abstraction around {@link Thread#sleep(long)} to permit better testability.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.ui.Sleeper} (Apache License 2.0).
+ */
+public interface Sleeper {
+ Sleeper SYSTEM_SLEEPER = duration -> Thread.sleep(duration.toMillis());
+
+ /**
+ * Sleeps for the specified duration of time.
+ *
+ * @param duration How long to sleep.
+ * @throws InterruptedException If the thread is interrupted while sleeping.
+ */
+ void sleep(Duration duration) throws InterruptedException;
+}
diff --git a/src/main/java/io/appium/java_client/support/ui/Wait.java b/src/main/java/io/appium/java_client/support/ui/Wait.java
new file mode 100644
index 000000000..bb3c49ed7
--- /dev/null
+++ b/src/main/java/io/appium/java_client/support/ui/Wait.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.ui;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.util.function.Function;
+
+/**
+ * A generic interface for waiting until a condition is true or not null.
+ * Adapted from Selenium's {@code org.openqa.selenium.support.ui.Wait} (Apache License 2.0).
+ *
+ * @param the argument to pass to any function called
+ */
+public interface Wait {
+
+ /**
+ * Implementations should wait until the condition evaluates to a value that is neither null nor
+ * false. Because of this contract, the return type must not be Void.
+ *
+ * If the condition does not become true within a certain time (as defined by the implementing
+ * class), this method will throw a non-specified {@link Throwable}. This is so that an
+ * implementor may throw whatever is idiomatic for a given test infrastructure (e.g. JUnit4 would
+ * throw {@link AssertionError}).
+ *
+ * @param the return type of the method, which must not be Void
+ * @param isTrue the function to evaluate against the input
+ * @return truthy value from the isTrue condition
+ */
+ @NonNull V until(Function super F, ? extends V> isTrue);
+}
diff --git a/src/test/java/io/appium/java_client/pagefactory/AppiumFieldDecoratorTest.java b/src/test/java/io/appium/java_client/pagefactory/AppiumFieldDecoratorTest.java
new file mode 100644
index 000000000..1dd22590f
--- /dev/null
+++ b/src/test/java/io/appium/java_client/pagefactory/AppiumFieldDecoratorTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.pagefactory;
+
+import io.appium.java_client.support.CacheLookup;
+import io.appium.java_client.support.FindBy;
+import io.appium.java_client.support.PageFactory;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.HasCapabilities;
+import org.openqa.selenium.MutableCapabilities;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.remote.RemoteWebElement;
+
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class AppiumFieldDecoratorTest {
+
+ private static final class FoundElement extends RemoteWebElement {
+ @Override
+ public String getText() {
+ return "text";
+ }
+ }
+
+ private static final class RecordingDriver {
+ private final List locators = new ArrayList<>();
+ private final WebDriver driver = (WebDriver) Proxy.newProxyInstance(
+ AppiumFieldDecoratorTest.class.getClassLoader(),
+ new Class>[]{WebDriver.class, HasCapabilities.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "findElement":
+ locators.add(args[0].toString());
+ return new FoundElement();
+ case "findElements":
+ locators.add(args[0].toString());
+ return List.of(new FoundElement(), new FoundElement());
+ case "getCapabilities":
+ return new MutableCapabilities();
+ default:
+ throw new UnsupportedOperationException(method.getName());
+ }
+ });
+ }
+
+ @SuppressWarnings("unused")
+ private static class Page {
+ @FindBy(id = "appium")
+ WebElement appiumAnnotated;
+
+ @org.openqa.selenium.support.FindBy(id = "selenium")
+ @org.openqa.selenium.support.CacheLookup
+ WebElement seleniumAnnotated;
+
+ @FindBy(css = ".many")
+ List many;
+
+ @CacheLookup
+ WebElement nameOrIdFallback;
+ }
+
+ private static Page initialized(RecordingDriver recorder) {
+ Page page = new Page();
+ PageFactory.initElements(new AppiumFieldDecorator(recorder.driver, Duration.ofMillis(100)), page);
+ return page;
+ }
+
+ @Test
+ void locatesElementsAnnotatedWithAppiumAnnotations() {
+ RecordingDriver recorder = new RecordingDriver();
+ Page page = initialized(recorder);
+
+ assertEquals("text", page.appiumAnnotated.getText());
+
+ assertEquals(List.of(By.id("appium").toString()), recorder.locators);
+ }
+
+ @Test
+ void locatesElementsAnnotatedWithSeleniumAnnotationsAndCachesThem() {
+ RecordingDriver recorder = new RecordingDriver();
+ Page page = initialized(recorder);
+
+ page.seleniumAnnotated.getText();
+ page.seleniumAnnotated.getText();
+
+ assertEquals(List.of(By.id("selenium").toString()), recorder.locators);
+ }
+
+ @Test
+ void locatesListsOfElements() {
+ RecordingDriver recorder = new RecordingDriver();
+ Page page = initialized(recorder);
+
+ assertEquals(2, page.many.size());
+
+ assertEquals(List.of(By.cssSelector(".many").toString()), recorder.locators);
+ }
+
+ @Test
+ void fallsBackToTheFieldNameWhenThereAreNoLocatorAnnotations() {
+ RecordingDriver recorder = new RecordingDriver();
+ Page page = initialized(recorder);
+
+ page.nameOrIdFallback.getText();
+
+ assertEquals(1, recorder.locators.size());
+ }
+}
diff --git a/src/test/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompatTest.java b/src/test/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompatTest.java
new file mode 100644
index 000000000..4b1057fed
--- /dev/null
+++ b/src/test/java/io/appium/java_client/pagefactory/SeleniumAnnotationsCompatTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.pagefactory;
+
+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.How;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SeleniumAnnotationsCompatTest {
+
+ @SuppressWarnings("unused")
+ private static class Page {
+ @org.openqa.selenium.support.FindBy(id = "foo")
+ @org.openqa.selenium.support.CacheLookup
+ WebElement seleniumShort;
+
+ @org.openqa.selenium.support.FindBy(how = org.openqa.selenium.support.How.XPATH, using = "//a")
+ WebElement seleniumLong;
+
+ @org.openqa.selenium.support.FindBys({
+ @org.openqa.selenium.support.FindBy(id = "foo"),
+ @org.openqa.selenium.support.FindBy(className = "bar")})
+ WebElement seleniumChained;
+
+ @org.openqa.selenium.support.FindAll({
+ @org.openqa.selenium.support.FindBy(name = "foo"),
+ @org.openqa.selenium.support.FindBy(tagName = "bar")})
+ List seleniumAll;
+
+ @FindBy(id = "appium")
+ @org.openqa.selenium.support.FindBy(id = "selenium")
+ WebElement both;
+
+ @FindBy(id = "appium")
+ @CacheLookup
+ WebElement appiumOnly;
+
+ WebElement none;
+ }
+
+ private static Field field(String name) throws NoSuchFieldException {
+ return Page.class.getDeclaredField(name);
+ }
+
+ private static DefaultElementByBuilder builderFor(String fieldName) throws NoSuchFieldException {
+ DefaultElementByBuilder builder = new DefaultElementByBuilder(null, null);
+ builder.setAnnotated(field(fieldName));
+ return builder;
+ }
+
+ @Test
+ void readsSeleniumFindByAttributes() throws NoSuchFieldException {
+ FindBy findBy = SeleniumAnnotationsCompat.find(field("seleniumShort"), FindBy.class);
+
+ assertNotNull(findBy);
+ assertEquals("foo", findBy.id());
+ assertEquals("", findBy.css());
+ assertEquals(How.UNSET, findBy.how());
+ assertEquals(FindBy.class, findBy.annotationType());
+ }
+
+ @Test
+ void convertsTheSeleniumHowEnum() throws NoSuchFieldException {
+ FindBy findBy = SeleniumAnnotationsCompat.find(field("seleniumLong"), FindBy.class);
+
+ assertNotNull(findBy);
+ assertEquals(How.XPATH, findBy.how());
+ assertEquals("//a", findBy.using());
+ }
+
+ @Test
+ void convertsNestedAnnotationArrays() throws NoSuchFieldException {
+ FindBys findBys = SeleniumAnnotationsCompat.find(field("seleniumChained"), FindBys.class);
+ FindAll findAll = SeleniumAnnotationsCompat.find(field("seleniumAll"), FindAll.class);
+
+ assertNotNull(findBys);
+ assertNotNull(findAll);
+ assertEquals(2, findBys.value().length);
+ assertEquals("bar", findBys.value()[1].className());
+ assertEquals("foo", findAll.value()[0].name());
+ assertEquals("bar", findAll.value()[1].tagName());
+ }
+
+ @Test
+ void detectsTheSeleniumCacheLookupMarker() throws NoSuchFieldException {
+ assertTrue(SeleniumAnnotationsCompat.isPresent(field("seleniumShort"), CacheLookup.class));
+ assertFalse(SeleniumAnnotationsCompat.isPresent(field("seleniumLong"), CacheLookup.class));
+ }
+
+ @Test
+ void appiumAnnotationsTakePrecedence() throws NoSuchFieldException {
+ FindBy findBy = SeleniumAnnotationsCompat.find(field("both"), FindBy.class);
+
+ assertNotNull(findBy);
+ assertEquals("appium", findBy.id());
+ }
+
+ @Test
+ void returnsNullWhenNothingIsPresent() throws NoSuchFieldException {
+ assertNull(SeleniumAnnotationsCompat.find(field("none"), FindBy.class));
+ assertNull(SeleniumAnnotationsCompat.find(field("appiumOnly"), FindAll.class));
+ }
+
+ @Test
+ void defaultElementByBuilderUnderstandsSeleniumAnnotations() throws NoSuchFieldException {
+ assertEquals(By.id("foo"), builderFor("seleniumShort").buildDefaultBy());
+ assertTrue(builderFor("seleniumShort").isLookupCached());
+ assertEquals(By.xpath("//a"), builderFor("seleniumLong").buildDefaultBy());
+ assertEquals("By.chained({By.id: foo,By.className: bar})",
+ builderFor("seleniumChained").buildDefaultBy().toString());
+ assertEquals("By.all({By.name: foo,By.tagName: bar})",
+ builderFor("seleniumAll").buildDefaultBy().toString());
+ }
+
+ @Test
+ void defaultElementByBuilderStillUnderstandsAppiumAnnotations() throws NoSuchFieldException {
+ assertEquals(By.id("appium"), builderFor("appiumOnly").buildDefaultBy());
+ assertTrue(builderFor("appiumOnly").isLookupCached());
+ assertFalse(builderFor("none").isLookupCached());
+ }
+}
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/DesktopBrowserCompatibilityTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/DesktopBrowserCompatibilityTest.java
index c918db58e..21bee8ff2 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/DesktopBrowserCompatibilityTest.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/DesktopBrowserCompatibilityTest.java
@@ -22,6 +22,9 @@
import io.appium.java_client.pagefactory.HowToUseLocators;
import io.appium.java_client.pagefactory.Widget;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
+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.utils.TestUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -29,9 +32,6 @@
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
-import org.openqa.selenium.support.FindBy;
-import org.openqa.selenium.support.FindBys;
-import org.openqa.selenium.support.PageFactory;
import java.util.List;
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/TimeoutTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/TimeoutTest.java
index 32d23c874..cfb3deadc 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/TimeoutTest.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/TimeoutTest.java
@@ -18,6 +18,8 @@
import io.appium.java_client.pagefactory.AppiumFieldDecorator;
import io.appium.java_client.pagefactory.WithTimeout;
+import io.appium.java_client.support.FindAll;
+import io.appium.java_client.support.FindBy;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -26,13 +28,12 @@
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
-import org.openqa.selenium.support.FindAll;
-import org.openqa.selenium.support.FindBy;
import java.time.Duration;
import java.util.List;
import static io.appium.java_client.pagefactory.AppiumFieldDecorator.DEFAULT_WAITING_TIMEOUT;
+import static io.appium.java_client.support.PageFactory.initElements;
import static io.github.bonigarcia.wdm.WebDriverManager.chromedriver;
import static java.lang.Math.abs;
import static java.lang.String.format;
@@ -42,7 +43,6 @@
import static org.apache.commons.lang3.time.DurationFormatUtils.formatDuration;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
-import static org.openqa.selenium.support.PageFactory.initElements;
public class TimeoutTest {
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/WidgetTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/WidgetTest.java
index 2f8e2d60d..79261cb82 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/WidgetTest.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/WidgetTest.java
@@ -7,11 +7,11 @@
import java.util.List;
+import static io.appium.java_client.support.PageFactory.initElements;
import static java.util.stream.Collectors.toList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
-import static org.openqa.selenium.support.PageFactory.initElements;
public abstract class WidgetTest {
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedAppTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedAppTest.java
index c7e50ef5f..31d9a8861 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedAppTest.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedAppTest.java
@@ -16,12 +16,12 @@
import java.util.List;
import java.util.stream.Stream;
+import static io.appium.java_client.support.PageFactory.initElements;
import static java.util.stream.Collectors.toList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.params.provider.Arguments.arguments;
-import static org.openqa.selenium.support.PageFactory.initElements;
@SuppressWarnings({"unused", "unchecked"})
public class CombinedAppTest {
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedWidgetTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedWidgetTest.java
index 26e0d2f74..647b92cce 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedWidgetTest.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/CombinedWidgetTest.java
@@ -18,12 +18,12 @@
import java.util.Map;
import java.util.stream.Stream;
+import static io.appium.java_client.support.PageFactory.initElements;
import static java.util.stream.Collectors.toList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.lessThan;
-import static org.openqa.selenium.support.PageFactory.initElements;
@SuppressWarnings({"unchecked", "unused"})
diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/DefaultFindByWidget.java b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/DefaultFindByWidget.java
index 45dd5685c..1e0a6bede 100644
--- a/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/DefaultFindByWidget.java
+++ b/src/test/java/io/appium/java_client/pagefactory_tests/widget/tests/combined/DefaultFindByWidget.java
@@ -1,8 +1,8 @@
package io.appium.java_client.pagefactory_tests.widget.tests.combined;
import io.appium.java_client.pagefactory_tests.widget.tests.DefaultStubWidget;
+import io.appium.java_client.support.FindBy;
import org.openqa.selenium.WebElement;
-import org.openqa.selenium.support.FindBy;
import java.util.List;
diff --git a/src/test/java/io/appium/java_client/support/FindByBuildersTest.java b/src/test/java/io/appium/java_client/support/FindByBuildersTest.java
new file mode 100644
index 000000000..f0fd51087
--- /dev/null
+++ b/src/test/java/io/appium/java_client/support/FindByBuildersTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import io.appium.java_client.support.pagefactory.ByAll;
+import io.appium.java_client.support.pagefactory.ByChained;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.WebElement;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FindByBuildersTest {
+
+ @SuppressWarnings("unused")
+ private static class Page {
+ @FindBy(id = "foo")
+ WebElement shortForm;
+
+ @FindBy(how = How.XPATH, using = "//a")
+ WebElement longForm;
+
+ @FindBy(id = "a", css = "b")
+ WebElement conflicting;
+
+ @FindBys({@FindBy(id = "foo"), @FindBy(className = "bar")})
+ WebElement chained;
+
+ @FindAll({@FindBy(how = How.NAME, using = "foo"), @FindBy(tagName = "bar")})
+ List all;
+
+ @FindBy(linkText = "a")
+ @CacheLookup
+ WebElement cached;
+ }
+
+ private static Field field(String name) throws NoSuchFieldException {
+ return Page.class.getDeclaredField(name);
+ }
+
+ @Test
+ void buildsByFromTheShortForm() throws NoSuchFieldException {
+ Field f = field("shortForm");
+
+ assertEquals(By.id("foo"), new FindBy.FindByBuilder().buildIt(f.getAnnotation(FindBy.class), f));
+ }
+
+ @Test
+ void buildsByFromTheLongForm() throws NoSuchFieldException {
+ Field f = field("longForm");
+
+ assertEquals(By.xpath("//a"), new FindBy.FindByBuilder().buildIt(f.getAnnotation(FindBy.class), f));
+ }
+
+ @Test
+ void rejectsMoreThanOneLocationStrategy() throws NoSuchFieldException {
+ Field f = field("conflicting");
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new FindBy.FindByBuilder().buildIt(f.getAnnotation(FindBy.class), f));
+ assertTrue(e.getMessage().contains("at most one location strategy"), e.getMessage());
+ }
+
+ @Test
+ void findBysBuildsAChain() throws NoSuchFieldException {
+ Field f = field("chained");
+
+ By by = new FindBys.FindByBuilder().buildIt(f.getAnnotation(FindBys.class), f);
+
+ assertInstanceOf(ByChained.class, by);
+ assertEquals("By.chained({By.id: foo,By.className: bar})", by.toString());
+ }
+
+ @Test
+ void findAllBuildsAnAnyOfLookup() throws NoSuchFieldException {
+ Field f = field("all");
+
+ By by = new FindAll.FindByBuilder().buildIt(f.getAnnotation(FindAll.class), f);
+
+ assertInstanceOf(ByAll.class, by);
+ assertEquals("By.all({By.name: foo,By.tagName: bar})", by.toString());
+ }
+
+ @Test
+ void cacheLookupIsARuntimeMarker() throws NoSuchFieldException {
+ assertTrue(field("cached").isAnnotationPresent(CacheLookup.class));
+ }
+
+ @Test
+ void howUnsetFallsBackToId() {
+ assertEquals(By.id("x"), How.UNSET.buildBy("x"));
+ }
+
+ @Test
+ void byIdOrNameFindsByIdFirstAndFallsBackToName() {
+ WebElement byName = Stubs.element("by name");
+
+ By by = new ByIdOrName("foo");
+
+ assertEquals(byName, by.findElement(Stubs.context(locator -> {
+ if (locator.toString().contains("name")) {
+ return List.of(byName);
+ }
+ throw new NoSuchElementException("not by id");
+ })));
+ }
+
+ @Test
+ void byIdOrNameFindsAllMatches() {
+ WebElement byId = Stubs.element("by id");
+ WebElement byName = Stubs.element("by name");
+
+ List found = new ByIdOrName("foo").findElements(Stubs.context(
+ locator -> locator.toString().contains("name") ? List.of(byName) : List.of(byId)));
+
+ assertEquals(List.of(byId, byName), found);
+ }
+}
diff --git a/src/test/java/io/appium/java_client/support/PageFactoryTest.java b/src/test/java/io/appium/java_client/support/PageFactoryTest.java
new file mode 100644
index 000000000..1e9f5a596
--- /dev/null
+++ b/src/test/java/io/appium/java_client/support/PageFactoryTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import io.appium.java_client.support.pagefactory.DefaultFieldDecorator;
+import io.appium.java_client.support.pagefactory.ElementLocator;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.WrapsElement;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 PageFactoryTest {
+
+ @SuppressWarnings("unused")
+ private static class BasePage {
+ @FindBy(id = "base")
+ WebElement baseElement;
+ }
+
+ @SuppressWarnings("unused")
+ private static class Page extends BasePage {
+ @FindBy(id = "single")
+ WebElement element;
+
+ @FindBy(css = ".many")
+ List elements;
+
+ String untouched = "untouched";
+
+ WebElement notAnnotatedElement;
+
+ @FindBy(id = "list-of-strings")
+ List notAnElementList;
+ }
+
+ private static ElementLocator locatorOf(String text) {
+ return new ElementLocator() {
+ @Override
+ public WebElement findElement() {
+ return Stubs.element(text);
+ }
+
+ @Override
+ public List findElements() {
+ return List.of(Stubs.element(text + "-1"), Stubs.element(text + "-2"));
+ }
+ };
+ }
+
+ @Test
+ void decoratesElementsAndListsIncludingInheritedFields() {
+ Page page = new Page();
+
+ PageFactory.initElements(new DefaultFieldDecorator(field -> locatorOf(field.getName())), page);
+
+ assertEquals("element", page.element.getText());
+ assertEquals("baseElement", page.baseElement.getText());
+ assertEquals(2, page.elements.size());
+ assertEquals("elements-2", page.elements.get(1).getText());
+ }
+
+ @Test
+ void doesNotTouchFieldsThatAreNotElements() {
+ Page page = new Page();
+
+ PageFactory.initElements(field -> locatorOf(field.getName()), page);
+
+ assertEquals("untouched", page.untouched);
+ assertNull(page.notAnElementList);
+ }
+
+ @Test
+ void elementProxiesExposeTheWrappedElement() {
+ Page page = new Page();
+
+ PageFactory.initElements(field -> locatorOf(field.getName()), page);
+
+ WebElement wrapped = assertInstanceOf(WrapsElement.class, page.element).getWrappedElement();
+ assertNotNull(wrapped);
+ assertEquals("element", wrapped.getText());
+ }
+
+ @Test
+ void elementProxiesReportTheLocatorWhenTheElementIsMissing() {
+ Page page = new Page();
+
+ PageFactory.initElements(field -> new ElementLocator() {
+ @Override
+ public WebElement findElement() {
+ throw new NoSuchElementException("missing");
+ }
+
+ @Override
+ public List findElements() {
+ return List.of();
+ }
+
+ @Override
+ public String toString() {
+ return "the locator";
+ }
+ }, page);
+
+ assertEquals("Proxy element for: the locator", page.element.toString());
+ assertThrows(NoSuchElementException.class, () -> page.element.getText());
+ assertTrue(page.elements.isEmpty());
+ }
+
+ @Test
+ void aFactoryReturningNullLeavesTheFieldAlone() {
+ Page page = new Page();
+
+ PageFactory.initElements(field -> null, page);
+
+ assertNull(page.element);
+ }
+}
diff --git a/src/test/java/io/appium/java_client/support/Stubs.java b/src/test/java/io/appium/java_client/support/Stubs.java
new file mode 100644
index 000000000..ac202b9ed
--- /dev/null
+++ b/src/test/java/io/appium/java_client/support/Stubs.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.SearchContext;
+import org.openqa.selenium.WebElement;
+
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.function.Function;
+
+final class Stubs {
+
+ private Stubs() {
+ }
+
+ static WebElement element(String text) {
+ return (WebElement) Proxy.newProxyInstance(
+ Stubs.class.getClassLoader(),
+ new Class>[]{WebElement.class},
+ (proxy, method, args) -> {
+ if ("getText".equals(method.getName())) {
+ return text;
+ }
+ switch (method.getName()) {
+ case "toString":
+ return "element " + text;
+ case "hashCode":
+ return System.identityHashCode(proxy);
+ case "equals":
+ return proxy == args[0];
+ default:
+ break;
+ }
+ throw new UnsupportedOperationException(method.getName());
+ });
+ }
+
+ static SearchContext context(Function> finder) {
+ return new SearchContext() {
+ @Override
+ public List findElements(By by) {
+ return finder.apply(by);
+ }
+
+ @Override
+ public WebElement findElement(By by) {
+ return finder.apply(by).get(0);
+ }
+ };
+ }
+}
diff --git a/src/test/java/io/appium/java_client/support/ui/FluentWaitTest.java b/src/test/java/io/appium/java_client/support/ui/FluentWaitTest.java
new file mode 100644
index 000000000..10dfde90e
--- /dev/null
+++ b/src/test/java/io/appium/java_client/support/ui/FluentWaitTest.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.support.ui;
+
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.TimeoutException;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FluentWaitTest {
+
+ private static final class FakeTime extends Clock implements Sleeper {
+ private Instant now = Instant.EPOCH;
+
+ @Override
+ public ZoneOffset getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(java.time.ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+
+ @Override
+ public void sleep(Duration duration) {
+ now = now.plus(duration);
+ }
+ }
+
+ private static FluentWait newWait(FakeTime time) {
+ return new FluentWait<>("input", time, time)
+ .withTimeout(Duration.ofSeconds(5))
+ .pollingEvery(Duration.ofSeconds(1));
+ }
+
+ @Test
+ void returnsTheFirstTruthyValue() {
+ assertEquals("INPUT", newWait(new FakeTime()).until(String::toUpperCase));
+ }
+
+ @Test
+ void pollsUntilTheConditionIsMet() {
+ AtomicInteger calls = new AtomicInteger();
+
+ Integer result = newWait(new FakeTime()).until(input -> calls.incrementAndGet() < 3 ? null : calls.get());
+
+ assertEquals(3, result);
+ }
+
+ @Test
+ void falseIsNotTruthy() {
+ AtomicInteger calls = new AtomicInteger();
+
+ assertEquals(true, newWait(new FakeTime()).until(input -> calls.incrementAndGet() >= 2));
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ void ignoredExceptionsAreRetried() {
+ AtomicInteger calls = new AtomicInteger();
+
+ String result = newWait(new FakeTime()).ignoring(NoSuchElementException.class).until(input -> {
+ if (calls.incrementAndGet() < 3) {
+ throw new NoSuchElementException("not yet");
+ }
+ return "found";
+ });
+
+ assertEquals("found", result);
+ }
+
+ @Test
+ void notIgnoredExceptionsArePropagated() {
+ assertThrows(IllegalStateException.class, () -> newWait(new FakeTime()).until(input -> {
+ throw new IllegalStateException("boom");
+ }));
+ }
+
+ @Test
+ void timesOutWithTheLastIgnoredExceptionAsTheCause() {
+ FakeTime time = new FakeTime();
+ NoSuchElementException cause = new NoSuchElementException("never there");
+
+ TimeoutException e = assertThrows(TimeoutException.class, () -> newWait(time)
+ .ignoring(NoSuchElementException.class)
+ .withMessage("the element")
+ .until(input -> {
+ throw cause;
+ }));
+
+ assertTrue(e.getMessage().contains("Expected condition failed: the element"), e.getMessage());
+ assertTrue(e.getMessage().contains("tried for 5 seconds with 1000 milliseconds interval"), e.getMessage());
+ assertEquals(cause, e.getCause());
+ assertTrue(time.instant().isAfter(Instant.EPOCH.plusSeconds(5)));
+ }
+
+ @Test
+ void zeroTimeoutStillEvaluatesTheConditionOnce() {
+ assertEquals("INPUT", newWait(new FakeTime()).withTimeout(Duration.ZERO).until(String::toUpperCase));
+ }
+
+ @Test
+ void interruptionIsReportedAsAWebDriverException() {
+ Sleeper interrupted = duration -> {
+ throw new InterruptedException();
+ };
+ FluentWait wait = new FluentWait<>("input", Clock.systemDefaultZone(), interrupted)
+ .withTimeout(Duration.ofSeconds(5));
+
+ try {
+ assertInstanceOf(org.openqa.selenium.WebDriverException.class,
+ assertThrows(RuntimeException.class, () -> wait.until(input -> null)));
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void formatsTimeouts() {
+ assertEquals("1 second", FluentWait.formatTimeout(Duration.ofSeconds(1)));
+ assertEquals("2 seconds", FluentWait.formatTimeout(Duration.ofSeconds(2)));
+ assertEquals("0.5 seconds", FluentWait.formatTimeout(Duration.ofMillis(500)));
+ }
+}