Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
<!-- END: project settings -->
<!-- BEGIN: compilation dependencies -->
<playwright.version>1.49.0</playwright.version>
<appium.version>10.1.1</appium.version>
<picocli.version>4.7.6</picocli.version>
<classgraph.version>4.8.174</classgraph.version>
<valid8j.version>2.1.3</valid8j.version>
Expand Down Expand Up @@ -127,6 +128,11 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>${appium.version}</version>
</dependency>
<dependency>
<groupId>com.eatthepath</groupId>
<artifactId>java-otp</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package jp.co.moneyforward.autotest.actions.mobile;

import com.github.valid8j.pcond.forms.Printables;
import io.appium.java_client.AppiumDriver;
import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment;
import org.openqa.selenium.By;

import java.util.function.Function;

///
/// An act that models a user behavior, which clicks a specified element.
///
public class Click extends ClickBase {
///
/// Creates an object of this class.
///
/// @param by A locator to designate an element to click.
///
public Click(By by) {
this(Printables.function("@" + by, (d) -> by));
}

///
/// Creates an object of this class.
///
/// @param locatorFunction A locator for an element to click.
///
public Click(Function<AppiumDriver, By> locatorFunction) {
super(locatorFunction);
}

@Override
public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) {
driver.findElement(this.locatorFunction.apply(driver)).click();
return driver;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package jp.co.moneyforward.autotest.actions.mobile;

import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
import jp.co.moneyforward.autotest.framework.action.Act;
import jp.co.moneyforward.autotest.framework.utils.InternalUtils;

import java.util.function.Function;

///
/// An abstract base class for clicking acts.
///
public abstract class ClickBase implements Act<AppiumDriver, AppiumDriver> {
final Function<AppiumDriver, By> locatorFunction;

///
/// Creates an object of this class.
///
/// @param locatorFunction A function to locate an element to click.
///
protected ClickBase(Function<AppiumDriver, By> locatorFunction) {
this.locatorFunction = locatorFunction;
}

///
/// Returns a name of this object.
/// The returned name is printed in action trees.
///
/// @return A name of this object.
///
@Override
public String name() {
return InternalUtils.simpleClassNameOf(this.getClass()) + "[" + this.locatorFunction + "]";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package jp.co.moneyforward.autotest.actions.mobile;

import com.github.valid8j.pcond.forms.Printables;
import io.appium.java_client.AppiumDriver;
import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

import java.util.List;
import java.util.function.Function;

///
/// An act that models a user behavior, which clicks a specified element only if it is present.
///
public class ClickIfPresent extends ClickBase {
///
/// Creates an object of this class.
///
/// @param by A locator to designate an element to click if present.
///
public ClickIfPresent(By by) {
this(Printables.function("@" + by, (d) -> by));
}

///
/// Creates an object of this class.
///
/// @param locatorFunction A function to locate an element to be clicked by this object on `perform` method's call.
///
public ClickIfPresent(Function<AppiumDriver, By> locatorFunction) {
super(locatorFunction);
}

@Override
public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) {
List<WebElement> elements = driver.findElements(this.locatorFunction.apply(driver));

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.

[3] Visibility guard dropped relative to the web ClickIfPresent.

The web version clicks only if targetElement.isVisible() (its Javadoc: "Check for presence is done by Locator#isVisible"). This copy clicks whenever findElements() returns a non-empty list, so an element that is present in the tree but not visible (off-screen, hidden view) gets clicked — or throws ElementNotInteractableException — which is exactly the "skip safely" case this class exists for.

Suggestion:

if (!elements.isEmpty() && elements.getFirst().isDisplayed()) {
  elements.getFirst().click();
}

if (!elements.isEmpty()) {
elements.getFirst().click();
}
return driver;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package jp.co.moneyforward.autotest.actions.mobile;

import com.github.valid8j.pcond.forms.Printables;
import org.openqa.selenium.WebElement;

import java.util.function.Function;

public enum ElementFunctions {;
private ElementFunctions() {
}

public static Function<WebElement, String> textContent() {
return Printables.function("textContent", WebElement::getText);
}

public static Function<WebElement, String> tagContent() {
return Printables.function("tagContent", WebElement::getTagName);
}

public static Function<WebElement, Boolean> isEnabled() {
return Printables.function("isEnabled", WebElement::isEnabled);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package jp.co.moneyforward.autotest.actions.mobile;

import com.github.valid8j.classic.Requires;
import com.github.valid8j.pcond.forms.Printables;
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

import java.util.function.Function;

///
/// A utility class to handle `AppiumDriver` object.
///
/// Methods in this class return a function whose parameter is an `AppiumDriver` object of **Appium**.
///
/// It is common to see a situation, where a single method call to a driver object cannot determine a single element to be returned.
/// In such a case, you can use functions provided by `ElementFunctions` in combination.
///
/// In general methods in this class are named in the following manner.
///
/// ```
/// {typeName}By{SelectionMethod}
/// ```
///
/// `typeName` can be, for instance, `locator`, `linkLocator`.
/// `SelectionMethod` can be `Name`, `Text`, `Label`, `Selector`, etc.
///
/// Functions returned by methods in this class can be pretty printed on a call of `toString` method call.
///
/// @see ElementFunctions
///
public enum PageFunctions {;

///
/// Returns a function that resolves a locator specified by `name` in a given `AppiumDriver` object.
///
/// This is a shorthand method for `linkLocatorByName(name, false)`.
///
/// @param name A name (accessibility id / content-desc) of a link-like element.
/// @return A function that resolves a locator specified by `name` in a given `AppiumDriver` object.
///
public static Function<AppiumDriver, WebElement> linkLocatorByName(String name) {

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.

[1] Type incompatibility: PageFunctions cannot feed the acts in this package.

Every method here returns Function<AppiumDriver, WebElement>, but every mobile act constructor (Click, ClickIfPresent, SendKey) accepts only Function<AppiumDriver, By>. In the web package the two halves share one currency — new ClickIfPresent(PageFunctions.locatorByText("hello")) compiles and is the documented usage (see BuiltInActsTest.java:83) — but the mobile equivalent is a compile error, so this whole utility class is unreachable from the acts it was written for. The new tests don't catch this because they exercise the two halves in isolation and never compose them.

Related: these functions call d.findElement(...) eagerly, so applying one when the element is absent throws NoSuchElementException at apply-time, instead of returning a lazy, retriable handle like the web Locator.

Suggestion — fix both at once: return Function<AppiumDriver, By> (build the By.xpath(...) lazily and let the act perform the single findElement). That restores the single-currency design and the lazy semantics, and avoids double element resolution.

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.

Follow-up with a concrete reproduction, verified against this PR's head (398ae96).

The branch itself compiles because nothing in the PR ever passes a PageFunctions result into an act — the new tests exercise each half in isolation. The incompatibility surfaces on the first attempt to compose them the way the web package documents and tests (cf. new ClickIfPresent(PageFunctions.locatorByText("hello")) in BuiltInActsTest). Adding this file:

package jp.co.moneyforward.autotest.ut.builtins;

import jp.co.moneyforward.autotest.actions.mobile.Click;
import jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent;
import jp.co.moneyforward.autotest.actions.mobile.PageFunctions;
import jp.co.moneyforward.autotest.actions.mobile.SendKey;

/// Mobile transplant of compositions the web package supports and tests.
class MobileCompositionRepro {
  void composeActsWithPageFunctions() {
    new Click(PageFunctions.buttonLocatorByName("Submit"));
    new ClickIfPresent(PageFunctions.locatorByText("hello"));
    new SendKey(PageFunctions.locatorByPlaceholder("Enter name"), "text");
  }
}

and running mvn test-compile fails on all three lines:

[ERROR] MobileCompositionRepro.java:[17,5] no suitable constructor found for Click(Function<AppiumDriver,WebElement>)
[ERROR]     constructor Click(Function<AppiumDriver,By>) is not applicable
[ERROR]       (argument mismatch; Function<AppiumDriver,WebElement> cannot be converted to Function<AppiumDriver,By>)
[ERROR] MobileCompositionRepro.java:[18,5] no suitable constructor found for ClickIfPresent(Function<AppiumDriver,WebElement>)
[ERROR] MobileCompositionRepro.java:[19,5] no suitable constructor found for SendKey(Function<AppiumDriver,WebElement>,String)

So every PageFunctions method is currently unreachable from every act in the package.

Suggested guard once the signatures are aligned (mirrors the existing web test, and would have turned this into a red build):

@Test
void givenMobilePageFunctionsLocator_whenConstructingActs_thenComposable() {
  AppiumDriver driver = Mockito.mock(AppiumDriver.class);
  ExecutionEnvironment env = Mockito.mock(ExecutionEnvironment.class);
  WebElement element = Mockito.mock(WebElement.class);
  when(driver.findElement(any(By.class))).thenReturn(element);
  when(driver.findElements(any(By.class))).thenReturn(List.of(element));

  // The point of this test is that these constructor calls COMPILE,
  // i.e. PageFunctions' return type is the acts' input type.
  new jp.co.moneyforward.autotest.actions.mobile.Click(PageFunctions.buttonLocatorByName("Submit")).perform(driver, env);
  new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(PageFunctions.locatorByText("hello")).perform(driver, env);
  new jp.co.moneyforward.autotest.actions.mobile.SendKey(PageFunctions.locatorByPlaceholder("Enter name"), "text").perform(driver, env);

  Mockito.verify(element, Mockito.atLeast(2)).click();
  Mockito.verify(element).sendKeys("text");
}

🤖 Generated with Claude Code

return linkLocatorByName(name, false);
}

///
/// Returns a function that resolves a given `name` to a locator of a link-like element whose accessibility
/// name matches with it.
///
/// @param name A name to be matched against `@content-desc` (Android) or `@name` (iOS).
/// @param lenient `true` - partial match / `false` - exact match.
/// @return A function that resolves a given `name` to a locator of a link-like element whose name matches with it.
///
public static Function<AppiumDriver, WebElement> linkLocatorByName(String name, boolean lenient) {
String xpath = lenient
? "//*[contains(@content-desc,'" + name + "') or contains(@name,'" + name + "')]"

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.

[2] XPath built by raw string concatenation breaks on any argument containing a single quote.

linkLocatorByName("O'Brien") produces //*[@content-desc='O'Brien' or @name='O'Brien']InvalidSelectorException at findElement. This affects all six locator builders in this class (linkLocatorByName, locatorByText, buttonLocatorByName, locatorByLabel, locatorByPlaceholder, linkLocatorByText). The web version is immune by construction (getByText/getByRole take the value as data, not markup).

Suggestion: prefer AppiumBy.accessibilityId(...) where applicable (it maps to content-desc on Android and name on iOS automatically, replacing the hand-maintained OR-xpaths), or route user text through a single shared XPath-literal quoting helper (concat('...', "'", '...') technique).

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.

これ、一回踏むと、デバッグつらそうなので、直した方がいいかも。(Fableとも相談した)

: "//*[@content-desc='" + name + "' or @name='" + name + "']";
return Printables.function("link[name" + (lenient ? "~" : "=") + name + "]",
d -> d.findElement(By.xpath(xpath)));
}

///
/// Returns a function that resolves a locator whose text contains `text` in a given driver.
///
/// @param text A text to be contained by the matching element.
/// @return A function that resolves a locator whose text contains `text` in a given driver.
///
public static Function<AppiumDriver, WebElement> locatorByText(String text) {
return locatorByText(text, false);
}

///
/// Returns a function that resolves a locator which matches `text` in a given `AppiumDriver` object.
/// If `lenient` is `true`, an element whose text contains it is considered matched.
/// If `lenient` is `false`, an element whose text equals to `text` is considered matched.
///
/// Matches against `@text` (Android) and `@label` / `@name` (iOS).
///
/// @param text A text to be matched.
/// @param lenient `true` - lenient / `false` - strict.
/// @return A function that resolves a locator which matches `text` in a given `AppiumDriver` object.
///
public static Function<AppiumDriver, WebElement> locatorByText(String text, boolean lenient) {
String xpath = lenient
? "//*[contains(@text,'" + text + "') or contains(@label,'" + text + "') or contains(@name,'" + text + "')]"
: "//*[@text='" + text + "' or @label='" + text + "' or @name='" + text + "']";
return Printables.function("@[text" + (lenient ? "~" : "=") + text + "]",
d -> d.findElement(By.xpath(xpath)));
}

///
/// Returns a function that resolves a locator to a button element in an `AppiumDriver`, whose name is equal to `name`.
///
/// Matches `android.widget.Button[@text]` on Android and `XCUIElementTypeButton[@name]` on iOS.
///
/// @param name A string to be matched with a button element's name.
/// @return A function that resolves a locator to a button element whose name is equal to `name`.
///
public static Function<AppiumDriver, WebElement> buttonLocatorByName(String name) {
return Printables.function("@[name=" + name + "]",
d -> d.findElement(By.xpath(
"//android.widget.Button[@text='" + name + "'] | //XCUIElementTypeButton[@name='" + name + "']")));
}

///
/// Returns a function that resolves a locator whose accessibility label matches with `label` in a given driver.
///
/// @param label A string to be matched with the accessibility label of a locator.
/// @return A function that resolves a locator whose label matches with `label`.
///
public static Function<AppiumDriver, WebElement> locatorByLabel(String label) {
return locatorByLabel(label, false);
}

///
/// Returns a function that resolves a locator whose accessibility label matches with `label` in a given driver.
///
/// Matches against `@content-desc` (Android) and `@label` (iOS).
///
/// If `lenient` is set to `true`, an element whose label contains `label` will be considered matched.
/// If it is `false`, an element whose label is equal to `label` will be considered matched.
///
/// @param label A string to be matched with an accessibility label of a locator.
/// @param lenient `true` - lenient / `false` - strict.
/// @return A function that resolves a locator whose label matches with `label` in a given driver.
///
public static Function<AppiumDriver, WebElement> locatorByLabel(String label, boolean lenient) {
String xpath = lenient
? "//*[contains(@content-desc,'" + label + "') or contains(@label,'" + label + "')]"
: "//*[@content-desc='" + label + "' or @label='" + label + "']";
return Printables.function("@[label" + (lenient ? "~" : "=") + label + "]",
d -> d.findElement(By.xpath(xpath)));
}

///
/// Returns a function that resolves a locator whose placeholder is `placeholder`.
///
/// Matches against `@hint` (Android) and `@placeholderValue` (iOS).
///
/// @param placeholder A string to be matched with a locator's placeholder.
/// @return A function that resolves a locator whose placeholder is `placeholder`.
///
public static Function<AppiumDriver, WebElement> locatorByPlaceholder(String placeholder) {
return Printables.function("@[placeholder=" + placeholder + "]",
d -> d.findElement(By.xpath(
"//*[@hint='" + placeholder + "' or @placeholderValue='" + placeholder + "']")));
}

///
/// Returns a function that resolves a locator specified by `by` in a given `AppiumDriver`.
///
/// @param by A `By` selector that specifies a locator.
/// @return A function that resolves a locator specified by `by` in a given `AppiumDriver`.
///
public static Function<AppiumDriver, WebElement> locatorBySelector(By by) {
Requires.requireNonNull(by);
return Printables.function("@[" + by + "]", d -> d.findElement(by));
}

///
/// Returns a function that gives a locator of a link-like element whose text contains a given `text`.
///
/// @param text A string to be contained in the text of a link-like element.
/// @return A function that gives a locator of a link-like element whose text contains a given `text`.
///
public static Function<AppiumDriver, WebElement> linkLocatorByText(String text) {
return linkLocatorByText(text, true);
}

///
/// Returns a function that gives a locator of a link-like element whose text equals to a given `text`.
///
/// @param text A string to be matched exactly with the text of a link-like element.
/// @return A function that gives a locator of a link-like element whose text equals to a given `text`.
///
public static Function<AppiumDriver, WebElement> linkLocatorByExactText(String text) {
return linkLocatorByText(text, false);
}

///
/// Returns a function that gives the title of the current screen / web view.
///
/// @return A function that gives the title of the given driver's current context.
///
public static Function<AppiumDriver, String> toTitle() {
return Printables.function("title", AppiumDriver::getTitle);
}

public static Function<AppiumDriver, WebElement> linkLocatorByText(String text, boolean lenient) {

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.

[7] linkLocatorByText builds byte-identical XPath to locatorByText — nothing restricts it to link-like elements.

Only the Printables label differs (link:@[...] vs @[...]). The Javadoc promises "a link-like element", and the web counterpart genuinely restricts via getByRole(AriaRole.LINK, ...), but this XPath matches //*. A user relying on the doc to disambiguate a link from a plain label with the same text gets an arbitrary matching node.

Suggestion: either delegate to locatorByText and fix the docs, or actually filter to link-like widget classes. The duplicated XPath string should collapse into one place either way.

String xpath = lenient
? "//*[contains(@text,'" + text + "') or contains(@label,'" + text + "') or contains(@name,'" + text + "')]"
: "//*[@text='" + text + "' or @label='" + text + "' or @name='" + text + "']";
return Printables.function("link:@[text" + (lenient ? "~" : "=") + text + "]",
d -> d.findElement(By.xpath(xpath)));
}
}
Loading
Loading