diff --git a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java index 907e7a177..2244e4b79 100644 --- a/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java +++ b/src/main/java/io/appium/java_client/pagefactory/AppiumFieldDecorator.java @@ -16,25 +16,34 @@ package io.appium.java_client.pagefactory; +import io.appium.java_client.AppiumBy; import io.appium.java_client.internal.CapabilityHelpers; +import io.appium.java_client.pagefactory.bys.ContentMappedBy; import io.appium.java_client.pagefactory.bys.ContentType; import io.appium.java_client.pagefactory.locator.CacheableLocator; import io.appium.java_client.remote.AppiumWebElement; import io.appium.java_client.remote.CapabilityType; +import io.appium.java_client.support.PageFactory; +import io.appium.java_client.support.pagefactory.ByAll; +import io.appium.java_client.support.pagefactory.ByChained; import io.appium.java_client.support.pagefactory.DefaultFieldDecorator; import io.appium.java_client.support.pagefactory.ElementLocator; import io.appium.java_client.support.pagefactory.ElementLocatorFactory; import io.appium.java_client.support.pagefactory.FieldDecorator; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +import org.openqa.selenium.By; import org.openqa.selenium.HasCapabilities; import org.openqa.selenium.SearchContext; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.lang.ref.WeakReference; import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; @@ -54,15 +63,22 @@ /** * Default decorator for use with PageFactory. Will decorate 1) all of the - * WebElement fields and 2) List of WebElement that have + * WebElement fields, 2) List of WebElement that have * {@literal @AndroidFindBy}, {@literal @AndroidFindBys}, or * {@literal @iOSFindBy/@iOSFindBys} annotation with a proxy that locates the - * elements using the passed in ElementLocatorFactory. - * Please pay attention: fields of {@link WebElement} or {@link AppiumWebElement} - * to use with this decorator + * elements using the passed in ElementLocatorFactory, and 3) non-static, non-final, + * uninitialized {@link By} / {@link AppiumBy} fields annotated with Appium or Selenium + * locator annotations (pre-initialized {@link By} fields are preserved when initialized via + * {@link PageFactory#initElements}). When a field is declared with a narrow {@link By} subtype + * (such as {@link AppiumBy}) and the built locator cannot be coerced to that subtype, + * a warning is logged and the field is left {@code null}. + * Please pay attention: fields of {@link WebElement}, {@link AppiumWebElement}, + * {@link By}, or {@link AppiumBy} are supported by this decorator. */ public class AppiumFieldDecorator implements FieldDecorator { + private static final Logger LOG = LoggerFactory.getLogger(AppiumFieldDecorator.class); + private static final List> AVAILABLE_ELEMENT_CLASSES = List.of( WebElement.class, AppiumWebElement.class @@ -265,6 +281,9 @@ public Object decorate(ClassLoader ignored, Field field) { CURRENT_DEFAULT_STRATEGY.set(defaultLocatorGroupStrategy); try { Object result = decorateWidget(field); + if (result == null) { + result = decorateBy(field); + } return result == null ? defaultElementFieldDecorator.decorate(ignored, field) : result; } finally { if (previous == LocatorGroupStrategy.CHAIN) { @@ -275,6 +294,83 @@ public Object decorate(ClassLoader ignored, Field field) { } } + @Nullable + private Object decorateBy(Field field) { + Class type = field.getType(); + int modifiers = field.getModifiers(); + if (!By.class.isAssignableFrom(type) + || Modifier.isStatic(modifiers) + || Modifier.isFinal(modifiers) + || PageFactory.isCurrentPageFieldInitialized(field)) { + return null; + } + + DefaultElementByBuilder builder = new DefaultElementByBuilder( + platform, automation, defaultLocatorGroupStrategy + ); + builder.setAnnotated(field); + builder.assertValidAnnotations(); + + By mobileNativeBy = builder.buildMobileNativeBy(); + By defaultBy = builder.buildDefaultBy(); + if (mobileNativeBy == null && defaultBy == null) { + return null; + } + if (type.isAssignableFrom(ContentMappedBy.class)) { + return builder.buildMappedBy(defaultBy, mobileNativeBy); + } + + By result = coerceToType(type, mobileNativeBy); + if (result == null) { + result = coerceToType(type, defaultBy); + } + if (result == null) { + By built = mobileNativeBy != null ? unwrapSingleBy(mobileNativeBy) : unwrapSingleBy(defaultBy); + LOG.warn( + "Cannot decorate field '{}.{}' of narrow type {}: built locator {} ({}) is not assignable to {}", + field.getDeclaringClass().getName(), + field.getName(), + type.getName(), + built, + built != null ? built.getClass().getName() : "null", + type.getName() + ); + } + return result; + } + + @Nullable + private static By coerceToType(Class type, @Nullable By candidate) { + if (candidate == null) { + return null; + } + if (type.isInstance(candidate)) { + return candidate; + } + By unwrapped = unwrapSingleBy(candidate); + return type.isInstance(unwrapped) ? unwrapped : null; + } + + @Nullable + private static By unwrapSingleBy(@Nullable By by) { + By current = by; + while (true) { + By[] bys; + if (current instanceof ByChained) { + bys = ((ByChained) current).getBys(); + } else if (current instanceof ByAll) { + bys = ((ByAll) current).getBys(); + } else { + break; + } + if (bys.length != 1) { + break; + } + current = bys[0]; + } + return current; + } + @Nullable private Object decorateWidget(Field field) { Class type = field.getType(); diff --git a/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java b/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java index b50c093ae..c24415f25 100644 --- a/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java +++ b/src/main/java/io/appium/java_client/pagefactory/DefaultElementByBuilder.java @@ -329,34 +329,30 @@ private By returnMappedBy(By byDefault, By nativeAppBy) { return new ContentMappedBy(contentMap); } - @Override - public By buildBy() { - assertValidAnnotations(); - - By defaultBy = buildDefaultBy(); - By mobileNativeBy = buildMobileNativeBy(); - + By buildMappedBy(@Nullable By defaultBy, @Nullable By mobileNativeBy) { String idOrName = ((Field) annotatedElementContainer.getAnnotated()).getName(); if (defaultBy == null && mobileNativeBy == null) { - defaultBy = new ByIdOrName(((Field) annotatedElementContainer.getAnnotated()).getName()); - mobileNativeBy = new By.ById(idOrName); - return returnMappedBy(defaultBy, mobileNativeBy); + return returnMappedBy(new ByIdOrName(idOrName), new By.ById(idOrName)); } if (defaultBy == null) { - defaultBy = new ByIdOrName(((Field) annotatedElementContainer.getAnnotated()).getName()); - return returnMappedBy(defaultBy, mobileNativeBy); + return returnMappedBy(new ByIdOrName(idOrName), mobileNativeBy); } if (mobileNativeBy == null) { - mobileNativeBy = defaultBy; - return returnMappedBy(defaultBy, mobileNativeBy); + return returnMappedBy(defaultBy, defaultBy); } return returnMappedBy(defaultBy, mobileNativeBy); } + @Override + public By buildBy() { + assertValidAnnotations(); + return buildMappedBy(buildDefaultBy(), buildMobileNativeBy()); + } + private static class AnnotationComparator implements Comparator { private static Method getPriorityMethod(Class clazz) { diff --git a/src/main/java/io/appium/java_client/pagefactory/bys/ContentMappedBy.java b/src/main/java/io/appium/java_client/pagefactory/bys/ContentMappedBy.java index 534a03916..cbe8d3394 100644 --- a/src/main/java/io/appium/java_client/pagefactory/bys/ContentMappedBy.java +++ b/src/main/java/io/appium/java_client/pagefactory/bys/ContentMappedBy.java @@ -18,21 +18,39 @@ import lombok.EqualsAndHashCode; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.openqa.selenium.By; import org.openqa.selenium.SearchContext; import org.openqa.selenium.WebElement; import java.util.List; import java.util.Map; +import java.util.Objects; +import static io.appium.java_client.pagefactory.bys.ContentType.HTML_OR_DEFAULT; import static io.appium.java_client.pagefactory.bys.ContentType.NATIVE_MOBILE_SPECIFIC; +import static io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility.getCurrentContentType; import static java.util.Objects.requireNonNull; -/** Locator which selects the underlying locator by the current content type. */ +/** + * Locator which selects the underlying locator by the active {@link ContentType}. + * + *

When a content type is explicitly pinned via {@link #useContent(ContentType)}, + * {@link #findElement(SearchContext)}, {@link #findElements(SearchContext)}, and + * {@link #toString()} use that pinned entry. When no content type has been set, + * {@link #findElement(SearchContext)} and {@link #findElements(SearchContext)} + * auto-detect the content type from the target {@link SearchContext} via + * {@link io.appium.java_client.pagefactory.utils.WebDriverUnpackUtility#getCurrentContentType(SearchContext)} + * (resolving to {@link ContentType#NATIVE_MOBILE_SPECIFIC} for native mobile contexts and + * {@link ContentType#HTML_OR_DEFAULT} for web/browser contexts or plain {@link SearchContext} + * implementations that implement neither {@link io.appium.java_client.HasBrowserCheck} nor + * {@link io.appium.java_client.remote.SupportsContextSwitching}). + */ @EqualsAndHashCode(callSuper = true) public class ContentMappedBy extends By { private final Map map; - private ContentType currentContent = NATIVE_MOBILE_SPECIFIC; + @Nullable + private ContentType currentContent; /** * Creates a new locator. @@ -57,16 +75,33 @@ public By useContent(@NonNull ContentType type) { @Override public WebElement findElement(SearchContext context) { - return context.findElement(map.get(currentContent)); + return context.findElement(map.get(resolveContentType(context))); } @Override public List findElements(SearchContext context) { - return context.findElements(map.get(currentContent)); + return context.findElements(map.get(resolveContentType(context))); } @Override public String toString() { - return map.get(currentContent).toString(); + if (currentContent != null) { + return map.get(currentContent).toString(); + } + By defaultBy = map.get(HTML_OR_DEFAULT); + By nativeBy = map.get(NATIVE_MOBILE_SPECIFIC); + if (Objects.equals(defaultBy, nativeBy) && nativeBy != null) { + return nativeBy.toString(); + } + return String.format( + "%s(%s=%s, %s=%s)", + getClass().getSimpleName(), + HTML_OR_DEFAULT, defaultBy, + NATIVE_MOBILE_SPECIFIC, nativeBy + ); + } + + private ContentType resolveContentType(SearchContext context) { + return currentContent != null ? currentContent : getCurrentContentType(context); } } diff --git a/src/main/java/io/appium/java_client/support/PageFactory.java b/src/main/java/io/appium/java_client/support/PageFactory.java index b469fd281..80a02f9ed 100644 --- a/src/main/java/io/appium/java_client/support/PageFactory.java +++ b/src/main/java/io/appium/java_client/support/PageFactory.java @@ -31,7 +31,7 @@ */ public final class PageFactory { - private static final ThreadLocal>> PAGE_CLASS_STACK = + private static final ThreadLocal> PAGE_OBJECT_STACK = ThreadLocal.withInitial(ArrayDeque::new); private PageFactory() { @@ -46,7 +46,31 @@ private PageFactory() { */ @Nullable public static Class getCurrentPageClass() { - return PAGE_CLASS_STACK.get().peek(); + Object current = PAGE_OBJECT_STACK.get().peek(); + return current != null ? current.getClass() : null; + } + + /** + * Checks whether the given {@code field} already has a non-null value on the page object + * currently being initialized by {@link #initElements(FieldDecorator, Object)} on the + * current thread. + * + * @param field the field to inspect + * @return {@code true} if the field can be inspected and already holds a non-null value + * on the active page object; {@code false} otherwise + */ + public static boolean isCurrentPageFieldInitialized(Field field) { + for (Object page : PAGE_OBJECT_STACK.get()) { + if (field.getDeclaringClass().isInstance(page)) { + try { + field.setAccessible(true); + return field.get(page) != null; + } catch (IllegalAccessException | RuntimeException e) { + return false; + } + } + } + return false; } /** @@ -69,8 +93,8 @@ public static void initElements(ElementLocatorFactory factory, Object page) { * @param page The object to decorate the fields of */ public static void initElements(FieldDecorator decorator, Object page) { - Deque> stack = PAGE_CLASS_STACK.get(); - stack.push(page.getClass()); + Deque stack = PAGE_OBJECT_STACK.get(); + stack.push(page); try { Class proxyIn = page.getClass(); while (proxyIn != Object.class) { @@ -80,7 +104,7 @@ public static void initElements(FieldDecorator decorator, Object page) { } finally { stack.pop(); if (stack.isEmpty()) { - PAGE_CLASS_STACK.remove(); + PAGE_OBJECT_STACK.remove(); } } } 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 index 4e2def133..84f3e5362 100644 --- a/src/main/java/io/appium/java_client/support/pagefactory/ByAll.java +++ b/src/main/java/io/appium/java_client/support/pagefactory/ByAll.java @@ -49,6 +49,15 @@ public ByAll(By... bys) { this.bys = bys; } + /** + * Returns the locators searched by this composite locator. + * + * @return a copy of the underlying locator array + */ + public By[] getBys() { + return bys == null ? new By[0] : bys.clone(); + } + @Override public WebElement findElement(SearchContext context) { for (By by : bys) { 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 index ff9c0e6b9..f2b5e0e13 100644 --- a/src/main/java/io/appium/java_client/support/pagefactory/ByChained.java +++ b/src/main/java/io/appium/java_client/support/pagefactory/ByChained.java @@ -50,6 +50,15 @@ public ByChained(By... bys) { this.bys = bys; } + /** + * Returns the locators that form this chain. + * + * @return a copy of the underlying locator array + */ + public By[] getBys() { + return bys == null ? new By[0] : bys.clone(); + } + @Override public WebElement findElement(SearchContext context) { List elements = findElements(context); diff --git a/src/test/java/io/appium/java_client/pagefactory_tests/ByFieldDecorationTest.java b/src/test/java/io/appium/java_client/pagefactory_tests/ByFieldDecorationTest.java new file mode 100644 index 000000000..12b729c81 --- /dev/null +++ b/src/test/java/io/appium/java_client/pagefactory_tests/ByFieldDecorationTest.java @@ -0,0 +1,485 @@ +/* + * 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_tests; + +import io.appium.java_client.AppiumBy; +import io.appium.java_client.pagefactory.AndroidBy; +import io.appium.java_client.pagefactory.AndroidFindAll; +import io.appium.java_client.pagefactory.AndroidFindBy; +import io.appium.java_client.pagefactory.AndroidFindBys; +import io.appium.java_client.pagefactory.AppiumFieldDecorator; +import io.appium.java_client.pagefactory.HowToUseLocators; +import io.appium.java_client.pagefactory.Widget; +import io.appium.java_client.pagefactory.WithTimeout; +import io.appium.java_client.pagefactory.bys.ContentMappedBy; +import io.appium.java_client.pagefactory.bys.builder.ByChained; +import io.appium.java_client.pagefactory.iOSXCUITBy; +import io.appium.java_client.pagefactory.iOSXCUITFindAll; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBys; +import io.appium.java_client.pagefactory_tests.widget.tests.AbstractStubWebDriver; +import io.appium.java_client.pagefactory_tests.widget.tests.StubWebElement; +import io.appium.java_client.remote.SupportsContextSwitching; +import io.appium.java_client.support.ByIdOrName; +import io.appium.java_client.support.CacheLookup; +import io.appium.java_client.support.FindAll; +import io.appium.java_client.support.FindBy; +import io.appium.java_client.support.FindBys; +import io.appium.java_client.support.pagefactory.ByAll; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static io.appium.java_client.pagefactory.LocatorGroupStrategy.ALL_POSSIBLE; +import static io.appium.java_client.pagefactory.bys.ContentType.HTML_OR_DEFAULT; +import static io.appium.java_client.pagefactory.bys.ContentType.NATIVE_MOBILE_SPECIFIC; +import static io.appium.java_client.support.PageFactory.initElements; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ByFieldDecorationTest { + + private interface ByRecorder { + List recordedBys(); + } + + private static class RecordingStubElement extends StubWebElement { + private final List recordedBys; + + RecordingStubElement(WebDriver driver, By by, List recordedBys) { + super(driver, by); + this.recordedBys = recordedBys; + } + + @Override + public RecordingStubElement findElement(By by) { + if (by instanceof ContentMappedBy) { + return (RecordingStubElement) by.findElement(this); + } + recordedBys.add(by); + return new RecordingStubElement(getWrappedDriver(), by, recordedBys); + } + + @Override + public List findElements(By by) { + if (by instanceof ContentMappedBy) { + return by.findElements(this); + } + recordedBys.add(by); + return List.of( + new RecordingStubElement(getWrappedDriver(), by, recordedBys), + new RecordingStubElement(getWrappedDriver(), by, recordedBys) + ); + } + } + + private static class ContextSwitchingAndroidDriver + extends AbstractStubWebDriver.StubAndroidDriver + implements SupportsContextSwitching, ByRecorder { + private final List recordedBys = new ArrayList<>(); + private String currentContext = "NATIVE_APP"; + private int contextCheckCount; + + @Override + public List recordedBys() { + return recordedBys; + } + + @Override + public WebDriver context(String name) { + this.currentContext = name; + return this; + } + + @Override + public boolean isBrowser() { + contextCheckCount++; + return !"NATIVE_APP".equals(currentContext); + } + + @Override + public String getContext() { + contextCheckCount++; + return currentContext; + } + + int getContextCheckCount() { + return contextCheckCount; + } + + @Override + public StubWebElement findElement(By by) { + if (by instanceof ContentMappedBy) { + return (StubWebElement) by.findElement(this); + } + recordedBys.add(by); + return new RecordingStubElement(this, by, recordedBys); + } + + @Override + public List findElements(By by) { + if (by instanceof ContentMappedBy) { + return by.findElements(this); + } + recordedBys.add(by); + return List.of( + new RecordingStubElement(this, by, recordedBys), + new RecordingStubElement(this, by, recordedBys) + ); + } + } + + private static class RecordingIOSDriver + extends AbstractStubWebDriver.StubIOSXCUITDriver + implements ByRecorder { + private final List recordedBys = new ArrayList<>(); + + @Override + public List recordedBys() { + return recordedBys; + } + + @Override + public StubWebElement findElement(By by) { + if (by instanceof ContentMappedBy) { + return (StubWebElement) by.findElement(this); + } + recordedBys.add(by); + return new RecordingStubElement(this, by, recordedBys); + } + + @Override + public List findElements(By by) { + if (by instanceof ContentMappedBy) { + return by.findElements(this); + } + recordedBys.add(by); + return List.of( + new RecordingStubElement(this, by, recordedBys), + new RecordingStubElement(this, by, recordedBys) + ); + } + } + + private static By lastRecordedBy(ByRecorder recorder, Runnable lookup) { + recorder.recordedBys().clear(); + lookup.run(); + List bys = recorder.recordedBys(); + return bys.get(bys.size() - 1); + } + + public static class CrossPlatformByPage { + @AndroidFindBy(id = "something_android") + @iOSXCUITFindBy(accessibility = "something_ios") + private By somethingBy; + + @AndroidFindBy(id = "appium_android") + @iOSXCUITFindBy(accessibility = "appium_ios") + private AppiumBy somethingAppiumBy; + + @FindBy(id = "web_only") + @AndroidFindBy(id = "native_android") + @iOSXCUITFindBy(accessibility = "native_ios") + private By hybridBy; + + @FindBy(css = ".web-button") + private By webFallbackBy; + + @AndroidFindBy(id = "chain1") + @AndroidFindBy(id = "chain2") + @iOSXCUITFindBy(id = "chain1") + @iOSXCUITFindBy(id = "chain2") + private ByChained multiLocatorBy; + + @HowToUseLocators(androidAutomation = ALL_POSSIBLE, iOSXCUITAutomation = ALL_POSSIBLE) + @AndroidFindBy(id = "any1") + @AndroidFindBy(id = "any2") + @iOSXCUITFindBy(id = "any1") + @iOSXCUITFindBy(id = "any2") + private ByAll allPossibleBy; + + @AndroidFindBys({@AndroidBy(id = "parent"), @AndroidBy(id = "child")}) + @iOSXCUITFindBys({@iOSXCUITBy(id = "parent"), @iOSXCUITBy(id = "child")}) + private ByChained findBysField; + + @AndroidFindAll({@AndroidBy(id = "opt1"), @AndroidBy(id = "opt2")}) + @iOSXCUITFindAll({@iOSXCUITBy(id = "opt1"), @iOSXCUITBy(id = "opt2")}) + private ByAll findAllField; + + @FindBy(id = "mapped_web") + @AndroidFindBy(id = "mapped_android") + private ContentMappedBy contentMappedBy; + + private ContentMappedBy unannotatedContentMappedBy; + + @FindBy(id = "ignored_mapped_web") + @AndroidFindBy(id = "ignored_mapped_native") + private ContentMappedBy preInitializedContentMappedBy = new ContentMappedBy(Map.of( + HTML_OR_DEFAULT, By.id("preset_mapped_web"), + NATIVE_MOBILE_SPECIFIC, AppiumBy.id("preset_mapped_native") + )); + + @CacheLookup + @WithTimeout(time = 5, chronoUnit = ChronoUnit.SECONDS) + @FindBy(id = "cached_web") + @AndroidFindBy(id = "cached_android") + private By cachedAndTimedBy; + + @AndroidFindBy(id = "widget_root") + @iOSXCUITFindBy(id = "widget_root") + private ByHoldingWidget widget; + + @FindBy(id = "ignored_final") + private final By finalAnnotatedBy = By.id("constant_id"); + + @FindBy(id = "ignored_static") + private static By staticAnnotatedBy = By.id("static_id"); + + @FindBy(id = "ignored_pre_initialized") + @AndroidFindBy(id = "ignored_pre_initialized_android") + @iOSXCUITFindBy(accessibility = "ignored_pre_initialized_ios") + private By preInitializedBy = By.id("preserved_id"); + + private By unannotatedNullBy; + } + + public static class ByHoldingWidget extends Widget { + @AndroidFindBy(accessibility = "inner_android") + @iOSXCUITFindBy(iOSNsPredicate = "name == 'inner_ios'") + private AppiumBy innerAppiumBy; + + protected ByHoldingWidget(WebElement element) { + super(element); + } + + public AppiumBy getInnerAppiumBy() { + return innerAppiumBy; + } + } + + public static class IncompatibleBySubTypePage { + @AndroidFindBy(xpath = "//android.widget.TextView") + private AppiumBy xpathAssignedToAppiumBy; + + @AndroidFindBy(id = "first") + @AndroidFindBy(id = "second") + private AppiumBy chainedAssignedToAppiumBy; + + @AndroidFindBy(id = "valid_android") + private AppiumBy validAppiumBy; + } + + public static class ConflictingSeleniumAnnotationsByPage { + @FindBy(id = "one") + @FindBys({@FindBy(id = "two"), @FindBy(id = "three")}) + private By conflictingBy; + } + + public static class ConflictingSeleniumOnAppiumByPage { + @FindBy(id = "one") + @FindAll({@FindBy(id = "two"), @FindBy(id = "three")}) + private AppiumBy conflictingAppiumBy; + } + + @Test + public void decoratesByAndAppiumByFieldsAndSwitchesContextLazilyOnAndroid() { + ContextSwitchingAndroidDriver driver = new ContextSwitchingAndroidDriver(); + CrossPlatformByPage page = new CrossPlatformByPage(); + initElements(new AppiumFieldDecorator(driver), page); + + // No remote context round-trips happen at initElements time + assertEquals(0, driver.getContextCheckCount()); + + assertInstanceOf(ContentMappedBy.class, page.somethingBy); + assertInstanceOf(ContentMappedBy.class, page.hybridBy); + assertInstanceOf(ContentMappedBy.class, page.webFallbackBy); + assertInstanceOf(ContentMappedBy.class, page.contentMappedBy); + assertInstanceOf(ContentMappedBy.class, page.cachedAndTimedBy); + + assertEquals(AppiumBy.id("appium_android"), page.somethingAppiumBy); + assertInstanceOf(ByChained.class, page.multiLocatorBy); + assertInstanceOf(ByAll.class, page.allPossibleBy); + assertInstanceOf(ByChained.class, page.findBysField); + assertInstanceOf(ByAll.class, page.findAllField); + assertEquals(AppiumBy.accessibilityId("inner_android"), page.widget.getInnerAppiumBy()); + + // Pre-initialized, final, static, and unannotated By / ContentMappedBy fields are left untouched + assertEquals(By.id("constant_id"), page.finalAnnotatedBy); + assertEquals(By.id("static_id"), CrossPlatformByPage.staticAnnotatedBy); + assertEquals(By.id("preserved_id"), page.preInitializedBy); + assertNull(page.unannotatedNullBy); + assertNull(page.unannotatedContentMappedBy); + assertEquals( + AppiumBy.id("preset_mapped_native"), + lastRecordedBy(driver, () -> driver.findElement(page.preInitializedContentMappedBy)) + ); + + final int initialHashCode = page.hybridBy.hashCode(); + + // In NATIVE_APP context, hybridBy and somethingBy resolve to native locators + assertEquals( + new ByChained(new By[]{AppiumBy.id("native_android")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.hybridBy)).toString() + ); + assertEquals( + new ByChained(new By[]{AppiumBy.id("something_android")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.somethingBy)).toString() + ); + + // Switching to WEBVIEW_1 updates the same By and ContentMappedBy fields lazily + driver.context("WEBVIEW_1"); + assertEquals( + By.id("web_only"), + lastRecordedBy(driver, () -> driver.findElement(page.hybridBy)) + ); + assertEquals( + By.id("web_only"), + lastRecordedBy(driver, () -> driver.findElements(page.hybridBy)) + ); + assertEquals( + By.id("mapped_web"), + lastRecordedBy(driver, () -> driver.findElement(page.contentMappedBy)) + ); + // Field with only @AndroidFindBy falls back to ByIdOrName in webview context + assertEquals( + new ByIdOrName("somethingBy").toString(), + lastRecordedBy(driver, () -> driver.findElement(page.somethingBy)).toString() + ); + + // Switching back to NATIVE_APP restores native locators, and hashCode remains stable + driver.context("NATIVE_APP"); + assertEquals( + new ByChained(new By[]{AppiumBy.id("native_android")}).toString(), + lastRecordedBy(driver, () -> driver.findElements(page.hybridBy)).toString() + ); + assertEquals(initialHashCode, page.hybridBy.hashCode()); + } + + @Test + public void explicitUseContentIsHonoredWithoutExtraContextChecksOrHashCodeMutation() { + Map locatorMap = Map.of( + HTML_OR_DEFAULT, By.id("web_target"), + NATIVE_MOBILE_SPECIFIC, AppiumBy.id("native_target") + ); + ContentMappedBy mappedBy = new ContentMappedBy(locatorMap); + ContentMappedBy webPinnedBy = new ContentMappedBy(locatorMap); + webPinnedBy.useContent(HTML_OR_DEFAULT); + + assertTrue(mappedBy.toString().contains("HTML_OR_DEFAULT=")); + assertTrue(mappedBy.toString().contains("NATIVE_MOBILE_SPECIFIC=")); + + // Explicitly select native content even when driver is in WEBVIEW_1 + ContextSwitchingAndroidDriver driver = new ContextSwitchingAndroidDriver(); + driver.context("WEBVIEW_1"); + mappedBy.useContent(NATIVE_MOBILE_SPECIFIC); + assertEquals(AppiumBy.id("native_target").toString(), mappedBy.toString()); + assertNotEquals(webPinnedBy, mappedBy); + + final int pinnedHash = mappedBy.hashCode(); + final int checksBefore = driver.getContextCheckCount(); + assertEquals( + AppiumBy.id("native_target"), + lastRecordedBy(driver, () -> driver.findElement(mappedBy)) + ); + assertEquals(checksBefore, driver.getContextCheckCount()); + assertEquals(pinnedHash, mappedBy.hashCode()); + } + + @Test + public void decoratesByAndAppiumByFieldsOnIOS() { + RecordingIOSDriver driver = new RecordingIOSDriver(); + CrossPlatformByPage page = new CrossPlatformByPage(); + initElements(new AppiumFieldDecorator(driver), page); + + assertEquals(AppiumBy.accessibilityId("appium_ios"), page.somethingAppiumBy); + assertEquals( + new ByChained(new By[]{AppiumBy.accessibilityId("something_ios")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.somethingBy)).toString() + ); + assertEquals( + new ByChained(new By[]{AppiumBy.accessibilityId("native_ios")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.hybridBy)).toString() + ); + assertEquals( + By.cssSelector(".web-button"), + lastRecordedBy(driver, () -> driver.findElement(page.webFallbackBy)) + ); + assertInstanceOf(ByChained.class, page.multiLocatorBy); + assertInstanceOf(ByAll.class, page.allPossibleBy); + assertInstanceOf(ByChained.class, page.findBysField); + assertInstanceOf(ByAll.class, page.findAllField); + assertEquals(AppiumBy.iOSNsPredicateString("name == 'inner_ios'"), page.widget.getInnerAppiumBy()); + assertEquals(By.id("constant_id"), page.finalAnnotatedBy); + assertEquals(By.id("preserved_id"), page.preInitializedBy); + assertNull(page.unannotatedNullBy); + assertNull(page.unannotatedContentMappedBy); + } + + @Test + public void respectsProgrammaticDefaultLocatorGroupStrategyForByFields() { + ContextSwitchingAndroidDriver driver = new ContextSwitchingAndroidDriver(); + CrossPlatformByPage page = new CrossPlatformByPage(); + initElements(new AppiumFieldDecorator(driver, ALL_POSSIBLE), page); + + assertEquals( + new ByAll(new By[]{AppiumBy.id("native_android")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.hybridBy)).toString() + ); + assertEquals( + new ByAll(new By[]{AppiumBy.id("something_android")}).toString(), + lastRecordedBy(driver, () -> driver.findElement(page.somethingBy)).toString() + ); + } + + @Test + public void leavesIncompatibleNarrowSubTypeFieldsNullWithoutAbortingPageInit() { + IncompatibleBySubTypePage page = new IncompatibleBySubTypePage(); + initElements(new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), page); + + assertNull(page.xpathAssignedToAppiumBy); + assertNull(page.chainedAssignedToAppiumBy); + assertEquals(AppiumBy.id("valid_android"), page.validAppiumBy); + } + + @Test + public void throwsOnConflictingAnnotationsOnByFields() { + assertThrows( + IllegalArgumentException.class, + () -> initElements( + new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), + new ConflictingSeleniumAnnotationsByPage() + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> initElements( + new AppiumFieldDecorator(new AbstractStubWebDriver.StubAndroidDriver()), + new ConflictingSeleniumOnAppiumByPage() + ) + ); + } +}