Skip to content

feat: allow configuring default LocatorGroupStrategy for page objects - #2480

Open
hoanle0126 wants to merge 3 commits into
appium:masterfrom
hoanle0126:feat/default-locator-group-strategy-2122
Open

hoanle0126 wants to merge 3 commits into
appium:masterfrom
hoanle0126:feat/default-locator-group-strategy-2122

Conversation

@hoanle0126

@hoanle0126 hoanle0126 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Proposed changes

Closes #2122

Allows configuring a custom default LocatorGroupStrategy (such as LocatorGroupStrategy.ALL_POSSIBLE) in one place instead of repeating @HowToUseLocators on every field:

  • Adds AppiumFieldDecorator(SearchContext, LocatorGroupStrategy) and AppiumFieldDecorator(SearchContext, Duration, LocatorGroupStrategy) constructors (plus DefaultElementByBuilder / WidgetByBuilder overloads) with Objects.requireNonNull validation.
  • New behavior: Honors class-level @HowToUseLocators annotations placed on page object / Widget classes, superclasses (@Inherited), interfaces, and enclosing classes.
  • Precedence order: field-level @HowToUseLocators > widget class @HowToUseLocators > page/declaring class @HowToUseLocators (including superclasses, interfaces, and enclosing classes) > AppiumFieldDecorator constructor defaultLocatorGroupStrategy > LocatorGroupStrategy.CHAIN.
  • Updates docs/Page-objects.md and adds unit tests in DefaultLocatorGroupStrategyTest.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

if (annotatedElement instanceof Field) {
Class<?> declaringClass = ((Field) annotatedElement).getDeclaringClass();
while (declaringClass != null && !declaringClass.equals(Object.class)) {
HowToUseLocators onClass = declaringClass.getAnnotation(HowToUseLocators.class);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Class-level @HowToUseLocators was allowed on types but ignored until now. Honoring it here changes behavior for existing pages that already carry the annotation (e.g. CHAIN -> ALL_POSSIBLE), unrelated to the new constructor. Is this intended? If so, it deserves an explicit mention in the PR description/changelog.

return Optional.of(direct);
}
if (annotatedElement instanceof Field) {
Class<?> declaringClass = ((Field) annotatedElement).getDeclaringClass();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The lookup starts at field.getDeclaringClass(), so it never sees the annotation on the page class actually being initialized. If a subclass is annotated but the fields are declared in an unannotated base page, the annotation is ignored. Enclosing classes and interfaces are not checked either. The new test only covers the annotation on the parent.

getBys(AndroidFindBy.class, AndroidFindBys.class, AndroidFindAll.class));
return buildMobileBy(
howToUseLocatorsOptional
.map(HowToUseLocators::androidAutomation)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HowToUseLocators.androidAutomation()/iOSXCUITAutomation() default to CHAIN, so a present annotation can't distinguish "unset" from an explicit CHAIN. E.g. decorator default ALL_POSSIBLE plus a field annotated @HowToUseLocators(androidAutomation = CHAIN): on iOS the annotation's default CHAIN still wins, so the configured default is not applied. Same for a class-level annotation that sets only one platform.

this.platform = readStringCapability(context, CapabilityType.PLATFORM_NAME);
this.automation = readStringCapability(context, AUTOMATION_NAME_OPTION);
this.duration = duration;
this.defaultLocatorGroupStrategy = defaultLocatorGroupStrategy != null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The null -> CHAIN fallback is repeated in five places (here, DefaultElementByBuilder, WidgetInterceptor, WidgetListInterceptor, and the 2-arg paths). A null passed by mistake is silently coerced. Consider Objects.requireNonNull or a single shared fallback.

* @param duration the timeout of waiting for an element presence
* @param defaultLocatorGroupStrategy the default strategy used when {@link HowToUseLocators} is not present
*/
public WidgetInterceptor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The strategy is threaded by hand through WidgetInterceptor, WidgetListInterceptor and AppiumFieldDecorator, each with a new public constructor overload, which enlarges the public API for internal plumbing. Anyone still using the old constructors (subclasses, third parties) silently falls back to CHAIN for nested widget fields.

import static io.appium.java_client.support.PageFactory.initElements;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class DefaultLocatorGroupStrategyTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Coverage gaps: no test for the List<Widget> path (WidgetListInterceptor), a nested widget with its own class-level annotation, a null strategy, the (SearchContext, LocatorGroupStrategy) overload, or a field-level annotation that sets only one platform.

public class DefaultLocatorGroupStrategyTest {

private static String unwrapByString(WebElement proxy) {
return proxy.toString();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Asserting on proxy.toString() ("Located by By.chained(...)") ties the tests to incidental message formats. They'd break if the proxy's toString or Selenium's By.chained/By.all toString changes, even with correct locator behavior, which is likely given the project tracks latest Selenium.

Comment thread docs/Page-objects.md Outdated
pageObject
);
```
You can also annotate the page object class itself with `@HowToUseLocators(androidAutomation = ALL_POSSIBLE, iOSXCUITAutomation = ALL_POSSIBLE)`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please clarify that class-level @HowToUseLocators is new behavior, that superclass annotations also apply, and which one wins when both a class annotation and the constructor default are set.

@hoanle0126

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @mykola-mokhnach! I have updated the PR to address all feedback:

  1. Null Validation: Replaced the repeated null -> CHAIN fallback with Objects.requireNonNull in AppiumFieldDecorator and DefaultElementByBuilder.
  2. No Public API Bloat on WidgetInterceptor / WidgetListInterceptor: Made the internal strategy constructors package-private and scoped the active decorator strategy so any subclass or caller invoking the existing 5-arg public constructors still inherits the configured default strategy for nested widget fields.
  3. Class-Level @HowToUseLocators Lookup & Documentation: Marked @HowToUseLocators as @Inherited, tracked the active page/widget class in PageFactory.initElements, and extended DefaultElementByBuilder to check widget classes, the runtime page class, superclasses, interfaces, and enclosing classes. Also documented the new class-level behavior, precedence order, and single-platform annotation attribute default (CHAIN) in docs/Page-objects.md, Javadocs, and the PR description.
  4. Behavioral Tests (DefaultLocatorGroupStrategyTest): Replaced proxy.toString() assertions with By instance assertions (ByChained vs ByAll) recorded during element lookups, and added test coverage for List<Widget>, widgets with class-level @HowToUseLocators, null strategy rejection, both AppiumFieldDecorator constructor overloads, single-platform @HowToUseLocators, annotated subclasses of unannotated base pages, interfaces, and enclosing classes.

Comment thread docs/Page-objects.md Outdated
);
```

- To change the default `LocatorGroupStrategy` (for example to `LocatorGroupStrategy.ALL_POSSIBLE`) for all fields that do not declare their own `@HowToUseLocators`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please also mention the minimum java client version where this feature has been added

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the minimum Appium Java Client version (11.1.0) to docs/Page-objects.md, thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Change default strategy for @HowToUseLocators programatically

2 participants