From 398ae9683b5e42d6f7fe0a92d3a853b60094a15e Mon Sep 17 00:00:00 2001 From: sukezan Date: Fri, 26 Jun 2026 22:24:23 +0900 Subject: [PATCH 01/12] develop actions for mobile --- pom.xml | 6 + .../autotest/actions/mobile/Click.java | 37 ++ .../autotest/actions/mobile/ClickBase.java | 35 ++ .../actions/mobile/ClickIfPresent.java | 42 +++ .../actions/mobile/ElementFunctions.java | 23 ++ .../actions/mobile/PageFunctions.java | 196 ++++++++++ .../autotest/actions/mobile/Screenshot.java | 47 +++ .../autotest/actions/mobile/SendKey.java | 66 ++++ .../autotest/ut/builtins/BuiltInActsTest.java | 339 ++++++++++++++++++ 9 files changed, 791 insertions(+) create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/Click.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickBase.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java diff --git a/pom.xml b/pom.xml index 29ef3469..8d00cd36 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,7 @@ 1.49.0 + 10.1.1 4.7.6 4.8.174 2.1.3 @@ -127,6 +128,11 @@ + + io.appium + java-client + ${appium.version} + com.eatthepath java-otp diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Click.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Click.java new file mode 100644 index 00000000..2253cc86 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Click.java @@ -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 locatorFunction) { + super(locatorFunction); + } + + @Override + public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) { + driver.findElement(this.locatorFunction.apply(driver)).click(); + return driver; + } +} \ No newline at end of file diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickBase.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickBase.java new file mode 100644 index 00000000..f930b681 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickBase.java @@ -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 { + final Function locatorFunction; + + /// + /// Creates an object of this class. + /// + /// @param locatorFunction A function to locate an element to click. + /// + protected ClickBase(Function 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 + "]"; + } +} \ No newline at end of file diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java new file mode 100644 index 00000000..2895f8a9 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java @@ -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 locatorFunction) { + super(locatorFunction); + } + + @Override + public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) { + List elements = driver.findElements(this.locatorFunction.apply(driver)); + if (!elements.isEmpty()) { + elements.getFirst().click(); + } + return driver; + } +} diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java new file mode 100644 index 00000000..31476ccc --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java @@ -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 textContent() { + return Printables.function("textContent", WebElement::getText); + } + + public static Function tagContent() { + return Printables.function("tagContent", WebElement::getTagName); + } + + public static Function isEnabled() { + return Printables.function("isEnabled", WebElement::isEnabled); + } +} diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java new file mode 100644 index 00000000..e27f5e2c --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java @@ -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 linkLocatorByName(String name) { + 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 linkLocatorByName(String name, boolean lenient) { + String xpath = lenient + ? "//*[contains(@content-desc,'" + name + "') or contains(@name,'" + name + "')]" + : "//*[@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 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 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 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 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 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 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 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 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 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 toTitle() { + return Printables.function("title", AppiumDriver::getTitle); + } + + public static Function linkLocatorByText(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("link:@[text" + (lenient ? "~" : "=") + text + "]", + d -> d.findElement(By.xpath(xpath))); + } +} \ No newline at end of file diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java new file mode 100644 index 00000000..297d6299 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java @@ -0,0 +1,47 @@ +package jp.co.moneyforward.autotest.actions.mobile; + +import io.appium.java_client.AppiumDriver; +import jp.co.moneyforward.autotest.framework.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; +import org.openqa.selenium.OutputType; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/// +/// An act that does screenshot. +/// The app screenshot is saved under `ExecutionEnvironment#testOutputFilenameFor("screenshot-{stepName}.png")`, where +/// `{stepName}` is one of `beforeAll`, `beforeEach`, `afterEach`, or `afterAll`. +/// +/// @see ExecutionEnvironment#testOutputFilenameFor(String) +/// +public class Screenshot implements Act { + /// + /// Creates an instance of this class. + /// + public Screenshot() { + // Make default constructor findable. + } + + /// + /// Performs the screenshot action. + /// + /// @param value A driver for which screenshot is executed. + /// @param executionEnvironment An execution environment. + /// @return The driver itself given as `value` parameter. + /// + @Override + public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) { + File screenshot = value.getScreenshotAs(OutputType.FILE); + try { + Files.copy(screenshot.toPath(), + new File(String.valueOf(executionEnvironment.testOutputFilenameFor(String.format("screenshot-%s.png", executionEnvironment.stepName())))).toPath(), + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException(e); + } + return value; + } +} \ No newline at end of file diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java new file mode 100644 index 00000000..2b0850a4 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java @@ -0,0 +1,66 @@ +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.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; +import org.openqa.selenium.By; + +import java.util.function.Function; +import java.util.function.Supplier; + +import static com.github.valid8j.classic.Requires.requireNonNull; + +public class SendKey implements Act { + /// + /// A prefix to control a + /// + public static final String MASK_PREFIX = "MASK!"; + private final Supplier keySequenceGenerator; + private final Function locatorFunction; + + /// + /// Creates an instance of this class. + /// + /// @param by A By function used to locate elements. + /// @param keys Keys to be sent to a locator chosen by a `by` function. + /// @see SendKey#SendKey(Function, String) + /// + public SendKey(By by, String keys) { + this(Printables.function("@" + by, (d) -> by), keys); + } + + /// + /// Creates an instance of this class. + /// + /// @param locatorFunction A function to choose a locator from a given driver. + /// @param keys Keys to be sent to a chosen locator. + /// + public SendKey(Function locatorFunction, String keys) { + this(locatorFunction, toSupplier(requireNonNull(keys))); + } + + /// + /// Creates an instance of this class. + /// + /// @param locatorFunction A function to choose a locator from a given driver. + /// @param keySequenceGenerator A supplier that generates a key sequence to be sent to a chosen locator. + /// + public SendKey(Function locatorFunction, Supplier keySequenceGenerator) { + this.locatorFunction = requireNonNull(locatorFunction); + this.keySequenceGenerator = requireNonNull(keySequenceGenerator); + } + + @Override + public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) { + By by = this.locatorFunction.apply(value); + String keys = keySequenceGenerator.get(); + value.findElement(by).sendKeys(keys.startsWith(MASK_PREFIX) ? keys.substring(MASK_PREFIX.length()) : keys); + + return value; + } + + private static Supplier toSupplier(String keys) { + return () -> keys; + } +} diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index cbfff184..e46a7329 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -2,14 +2,22 @@ import com.github.valid8j.pcond.forms.Printables; import com.microsoft.playwright.*; +import io.appium.java_client.AppiumDriver; +import jp.co.moneyforward.autotest.actions.mobile.ElementFunctions; import jp.co.moneyforward.autotest.actions.web.*; import jp.co.moneyforward.autotest.framework.action.Act; import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; import jp.co.moneyforward.autotest.ututils.TestBase; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static com.github.valid8j.fluent.Expectations.*; @@ -276,4 +284,335 @@ void givenSinkWithoutName_whenPerformed_thenGivenConsumerExercised() { assertStatement(value(valueHolder).invoke("get").toBe().equalTo("XYZ")); } + + // Mobile action tests + + @Test + void givenMobileElement_whenPerformMobileClick_thenElementIsClicked() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + WebElement element = Mockito.mock(WebElement.class); + By by = By.id("someId"); + when(driver.findElement(by)).thenReturn(element); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.Click(by).perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(element).click(); + } + + @Test + void givenMobileClick_whenName_thenNameContainsClick() { + jp.co.moneyforward.autotest.actions.mobile.Click act = + new jp.co.moneyforward.autotest.actions.mobile.Click(By.id("someId")); + + String name = act.name(); + + assertAll(value(name).toBe().containing("Click")); + } + + @Test + void givenPresentMobileElement_whenPerformMobileClickIfPresent_thenElementIsClicked() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + WebElement element = Mockito.mock(WebElement.class); + By by = By.id("someId"); + when(driver.findElements(by)).thenReturn(List.of(element)); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(by).perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(element).click(); + } + + @Test + void givenAbsentMobileElement_whenPerformMobileClickIfPresent_thenNoClickPerformed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + By by = By.id("someId"); + when(driver.findElements(by)).thenReturn(List.of()); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(by).perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(driver, never()).findElement(any()); + } + + @Test + void givenMobileClickIfPresent_whenName_thenNameContainsClickIfPresent() { + jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent act = + new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(By.id("someId")); + + String name = act.name(); + + assertAll(value(name).toBe().containing("ClickIfPresent")); + } + + @Test + void whenElementFunctionsTextContent_thenGetTextCalledOnElement() { + WebElement element = Mockito.mock(WebElement.class); + when(element.getText()).thenReturn("Hello"); + + String text = ElementFunctions.textContent().apply(element); + + assertAll(value(text).toBe().equalTo("Hello")); + Mockito.verify(element).getText(); + } + + @Test + void whenElementFunctionsTagContent_thenGetTagNameCalledOnElement() { + WebElement element = Mockito.mock(WebElement.class); + when(element.getTagName()).thenReturn("button"); + + String tag = ElementFunctions.tagContent().apply(element); + + assertAll(value(tag).toBe().equalTo("button")); + Mockito.verify(element).getTagName(); + } + + @Test + void whenElementFunctionsIsEnabled_thenIsEnabledCalledOnElement() { + WebElement element = Mockito.mock(WebElement.class); + when(element.isEnabled()).thenReturn(true); + + Boolean enabled = ElementFunctions.isEnabled().apply(element); + + assertAll(value(enabled).toBe().equalTo(true)); + Mockito.verify(element).isEnabled(); + } + + @Test + void whenPageFunctionsLinkLocatorByName_thenExactMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("@content-desc='hello'") + .containing("@name='hello'")); + } + + @Test + void whenPageFunctionsLinkLocatorByNameLenient_thenContainsMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello", true).apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("contains(@content-desc,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsLocatorByText_thenExactMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("@text='hello'") + .containing("@label='hello'") + .containing("@name='hello'")); + } + + @Test + void whenPageFunctionsLocatorByTextLenient_thenContainsMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello", true).apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsButtonLocatorByName_thenCorrectXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.buttonLocatorByName("Submit").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("android.widget.Button[@text='Submit']") + .containing("XCUIElementTypeButton[@name='Submit']")); + } + + @Test + void whenPageFunctionsLocatorByLabel_thenExactMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("@content-desc='myLabel'") + .containing("@label='myLabel'")); + } + + @Test + void whenPageFunctionsLocatorByLabelLenient_thenContainsMatchXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel", true).apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("contains(@content-desc,'myLabel')") + .containing("contains(@label,'myLabel')")); + } + + @Test + void whenPageFunctionsLocatorByPlaceholder_thenCorrectXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByPlaceholder("Enter name").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("@hint='Enter name'") + .containing("@placeholderValue='Enter name'")); + } + + @Test + void whenPageFunctionsLocatorBySelector_thenFindElementCalledWithGivenBy() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + By by = By.id("targetId"); + when(driver.findElement(by)).thenReturn(element); + + WebElement result = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorBySelector(by).apply(driver); + + assertAll(value(result).toBe().equalTo(element)); + Mockito.verify(driver).findElement(by); + } + + @Test + void whenPageFunctionsLinkLocatorByText_thenLenientXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByText("hello").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsLinkLocatorByExactText_thenExactXpathUsed() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + WebElement element = Mockito.mock(WebElement.class); + ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); + when(driver.findElement(any(By.class))).thenReturn(element); + + jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByExactText("hello").apply(driver); + + Mockito.verify(driver).findElement(byCaptor.capture()); + assertStatement(value(byCaptor.getValue().toString()).toBe() + .containing("@text='hello'") + .containing("@label='hello'") + .containing("@name='hello'")); + } + + @Test + void whenPageFunctionsToTitle_thenDriverGetTitleIsCalled() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + when(driver.getTitle()).thenReturn("My Title"); + + String title = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.toTitle().apply(driver); + + assertAll(value(title).toBe().equalTo("My Title")); + Mockito.verify(driver).getTitle(); + } + + @Test + void whenPerformMobileScreenshot_thenScreenshotCopiedAndDriverReturned() throws IOException { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + when(executionEnvironment.stepName()).thenReturn("TEST_STEP"); + File sourceFile = File.createTempFile("screenshot-source", ".png"); + sourceFile.deleteOnExit(); + Path destPath = File.createTempFile("screenshot-dest", ".png").toPath(); + when(driver.getScreenshotAs(any())).thenReturn(sourceFile); + when(executionEnvironment.testOutputFilenameFor(any(String.class))).thenReturn(destPath); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.Screenshot().perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(driver).getScreenshotAs(any()); + } + + @Test + void givenUnmaskedKey_whenPerformMobileSendKey_thenKeysSentToElement() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + WebElement element = Mockito.mock(WebElement.class); + By by = By.id("inputField"); + when(driver.findElement(by)).thenReturn(element); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.SendKey(by, "myPassword").perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(element).sendKeys("myPassword"); + } + + @Test + void givenMaskedKey_whenPerformMobileSendKey_thenUnmaskedKeysSentToElement() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); + WebElement element = Mockito.mock(WebElement.class); + By by = By.id("inputField"); + when(driver.findElement(by)).thenReturn(element); + + AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.SendKey( + by, jp.co.moneyforward.autotest.actions.mobile.SendKey.MASK_PREFIX + "myPassword" + ).perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(element).sendKeys("myPassword"); + } + + @Test + void givenMobileSendKey_whenName_thenNameContainsSendKey() { + jp.co.moneyforward.autotest.actions.mobile.SendKey act = + new jp.co.moneyforward.autotest.actions.mobile.SendKey(By.id("field"), "keys"); + + String name = act.name(); + + assertAll(value(name).toBe().containing("SendKey")); + } } From a1d192c2fce60f3c72adefd892541044d9bf65f4 Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 2 Jul 2026 10:48:21 +0900 Subject: [PATCH 02/12] fix: check element visibility in ClickIfPresent to avoid interacting with hidden views --- .../co/moneyforward/autotest/actions/mobile/ClickIfPresent.java | 2 +- .../co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java index 2895f8a9..aafeae2a 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ClickIfPresent.java @@ -34,7 +34,7 @@ public ClickIfPresent(Function locatorFunction) { @Override public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) { List elements = driver.findElements(this.locatorFunction.apply(driver)); - if (!elements.isEmpty()) { + if (!elements.isEmpty() && elements.getFirst().isDisplayed()) { elements.getFirst().click(); } return driver; diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index e46a7329..51bc5495 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -318,6 +318,7 @@ void givenPresentMobileElement_whenPerformMobileClickIfPresent_thenElementIsClic WebElement element = Mockito.mock(WebElement.class); By by = By.id("someId"); when(driver.findElements(by)).thenReturn(List.of(element)); + when(element.isDisplayed()).thenReturn(true); AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(by).perform(driver, executionEnvironment); From 0b0a10b6f37e191e15e9883de0b32175f930c517 Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 2 Jul 2026 16:49:44 +0900 Subject: [PATCH 03/12] fix: create parent dirs and switch to bytes output to avoid leaks --- .../autotest/actions/mobile/Screenshot.java | 10 ++++------ .../autotest/ut/builtins/BuiltInActsTest.java | 10 +++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java index 297d6299..d4296e36 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java @@ -5,10 +5,9 @@ import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; import org.openqa.selenium.OutputType; -import java.io.File; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.StandardCopyOption; +import java.nio.file.Path; /// /// An act that does screenshot. @@ -34,11 +33,10 @@ public Screenshot() { /// @Override public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) { - File screenshot = value.getScreenshotAs(OutputType.FILE); try { - Files.copy(screenshot.toPath(), - new File(String.valueOf(executionEnvironment.testOutputFilenameFor(String.format("screenshot-%s.png", executionEnvironment.stepName())))).toPath(), - StandardCopyOption.REPLACE_EXISTING); + Path destination = executionEnvironment.testOutputFilenameFor(String.format("screenshot-%s.png", executionEnvironment.stepName())); + Files.createDirectories(destination.getParent()); + Files.write(destination, value.getScreenshotAs(OutputType.BYTES)); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index 51bc5495..82eea939 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -565,15 +565,15 @@ void whenPerformMobileScreenshot_thenScreenshotCopiedAndDriverReturned() throws AppiumDriver driver = Mockito.mock(AppiumDriver.class); ExecutionEnvironment executionEnvironment = Mockito.mock(ExecutionEnvironment.class); when(executionEnvironment.stepName()).thenReturn("TEST_STEP"); - File sourceFile = File.createTempFile("screenshot-source", ".png"); - sourceFile.deleteOnExit(); - Path destPath = File.createTempFile("screenshot-dest", ".png").toPath(); - when(driver.getScreenshotAs(any())).thenReturn(sourceFile); + Path destPath = Path.of(System.getProperty("java.io.tmpdir"), "screenshot-test-" + System.nanoTime(), "screenshot-dest.png"); + when(driver.getScreenshotAs(any())).thenReturn(new byte[]{1, 2, 3}); when(executionEnvironment.testOutputFilenameFor(any(String.class))).thenReturn(destPath); AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.Screenshot().perform(driver, executionEnvironment); - assertAll(value(returned).toBe().equalTo(driver)); + assertAll( + value(returned).toBe().equalTo(driver), + value(destPath.toFile().exists()).toBe().equalTo(true)); Mockito.verify(driver).getScreenshotAs(any()); } From 0856cfa3badda66400be4b3f073334a07d6b5cde Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 2 Jul 2026 17:31:05 +0900 Subject: [PATCH 04/12] refactor: centralize MASK_PREFIX constant to avoid duplication --- .../co/moneyforward/autotest/actions/mobile/SendKey.java | 5 +---- .../jp/co/moneyforward/autotest/actions/web/SendKey.java | 5 +---- .../autotest/framework/utils/InternalUtils.java | 2 +- .../autotest/ut/builtins/BuiltInActsTest.java | 9 +++++---- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java index 2b0850a4..dfcc90f0 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java @@ -10,12 +10,9 @@ import java.util.function.Supplier; import static com.github.valid8j.classic.Requires.requireNonNull; +import static jp.co.moneyforward.autotest.framework.utils.InternalUtils.MASK_PREFIX; public class SendKey implements Act { - /// - /// A prefix to control a - /// - public static final String MASK_PREFIX = "MASK!"; private final Supplier keySequenceGenerator; private final Function locatorFunction; diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/web/SendKey.java b/src/main/java/jp/co/moneyforward/autotest/actions/web/SendKey.java index f21e39b6..10b42205 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/web/SendKey.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/web/SendKey.java @@ -10,6 +10,7 @@ import java.util.function.Supplier; import static com.github.valid8j.classic.Requires.requireNonNull; +import static jp.co.moneyforward.autotest.framework.utils.InternalUtils.MASK_PREFIX; /// /// A class that represents an action to send key sequence to a specified locator. @@ -20,10 +21,6 @@ /// /// public class SendKey implements Act { - /// - /// A prefix to control a - /// - public static final String MASK_PREFIX = "MASK!"; private final Supplier keySequenceGenerator; private final Function locatorFunction; diff --git a/src/main/java/jp/co/moneyforward/autotest/framework/utils/InternalUtils.java b/src/main/java/jp/co/moneyforward/autotest/framework/utils/InternalUtils.java index e221ddff..1b315f2b 100644 --- a/src/main/java/jp/co/moneyforward/autotest/framework/utils/InternalUtils.java +++ b/src/main/java/jp/co/moneyforward/autotest/framework/utils/InternalUtils.java @@ -31,7 +31,6 @@ import static java.lang.Thread.currentThread; import static java.nio.file.StandardOpenOption.APPEND; import static java.nio.file.StandardOpenOption.CREATE; -import static jp.co.moneyforward.autotest.actions.web.SendKey.MASK_PREFIX; /// /// An internal utility class of the **insdog** framework. @@ -39,6 +38,7 @@ public enum InternalUtils { ; + public static final String MASK_PREFIX = "MASK!"; public static final Logger LOGGER = LoggerFactory.getLogger(InternalUtils.class); /// diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index 82eea939..55d62208 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicReference; import static com.github.valid8j.fluent.Expectations.*; +import static jp.co.moneyforward.autotest.framework.utils.InternalUtils.MASK_PREFIX; import static org.mockito.Mockito.*; class BuiltInActsTest extends TestBase { @@ -128,7 +129,7 @@ void givenMaskedString_whenSendkey_thenUnmaskedStringIsTypedIntoLocator() { when(page.locator(any())).thenReturn(locator); when(page.keyboard()).thenReturn(keyboard); - Page returned = new SendKey("hello", SendKey.MASK_PREFIX + "keysToBeSentToHello").perform(page, executionEnvironment); + Page returned = new SendKey("hello", MASK_PREFIX + "keysToBeSentToHello").perform(page, executionEnvironment); assertAll(value(returned).toBe().equalTo(page)); Mockito.verify(locator).focus(); @@ -138,7 +139,7 @@ void givenMaskedString_whenSendkey_thenUnmaskedStringIsTypedIntoLocator() { @Test void givenMaskedString_whenName_thenNameLooksOkWithoutUnmaskedString() { - SendKey act = new SendKey("hello", SendKey.MASK_PREFIX + "keysToBeSentToHello"); + SendKey act = new SendKey("hello", MASK_PREFIX + "keysToBeSentToHello"); String name = act.name(); @@ -146,7 +147,7 @@ void givenMaskedString_whenName_thenNameLooksOkWithoutUnmaskedString() { assertStatement(value(name).toBe() .containing("SendKey") .not(v -> v.containing("keysToBeSentToHello")) - .containing(SendKey.MASK_PREFIX)); + .containing(MASK_PREFIX)); } @Test @@ -600,7 +601,7 @@ void givenMaskedKey_whenPerformMobileSendKey_thenUnmaskedKeysSentToElement() { when(driver.findElement(by)).thenReturn(element); AppiumDriver returned = new jp.co.moneyforward.autotest.actions.mobile.SendKey( - by, jp.co.moneyforward.autotest.actions.mobile.SendKey.MASK_PREFIX + "myPassword" + by, MASK_PREFIX + "myPassword" ).perform(driver, executionEnvironment); assertAll(value(returned).toBe().equalTo(driver)); From 5a29f520c6d2a9b39c04234282ca6983231d15b7 Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 2 Jul 2026 17:40:13 +0900 Subject: [PATCH 05/12] fix: override name() in SendKey for mobile to include locator and mask in logs --- .../autotest/actions/mobile/SendKey.java | 8 ++++++++ .../autotest/ut/builtins/BuiltInActsTest.java | 20 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java index dfcc90f0..3f7df0a2 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java @@ -57,6 +57,14 @@ public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEn return value; } + @Override + public String name() { + String keys = keySequenceGenerator.get(); + return Act.super.name() + "[" + locatorFunction + "][" + + (keys.startsWith(MASK_PREFIX) ? MASK_PREFIX + : keys) + "]"; + } + private static Supplier toSupplier(String keys) { return () -> keys; } diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index 55d62208..cfdc7ad3 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -609,12 +609,28 @@ void givenMaskedKey_whenPerformMobileSendKey_thenUnmaskedKeysSentToElement() { } @Test - void givenMobileSendKey_whenName_thenNameContainsSendKey() { + void givenMobileSendKey_whenName_thenNameContainsSendKeyAndLocatorAndKeys() { jp.co.moneyforward.autotest.actions.mobile.SendKey act = new jp.co.moneyforward.autotest.actions.mobile.SendKey(By.id("field"), "keys"); String name = act.name(); - assertAll(value(name).toBe().containing("SendKey")); + assertStatement(value(name).toBe() + .containing("SendKey") + .containing("field") + .containing("keys")); + } + + @Test + void givenMobileSendKeyWithMaskedKey_whenName_thenNameContainsMaskPrefixNotSecret() { + jp.co.moneyforward.autotest.actions.mobile.SendKey act = + new jp.co.moneyforward.autotest.actions.mobile.SendKey(By.id("field"), MASK_PREFIX + "secret"); + + String name = act.name(); + + assertStatement(value(name).toBe() + .containing("SendKey") + .containing(MASK_PREFIX) + .not(v -> v.containing("secret"))); } } From 90f7cdc899bca17042bc3c374a1afcc9019ddccb Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 2 Jul 2026 17:50:40 +0900 Subject: [PATCH 06/12] refactor: eliminate duplicate xpath by delegating linkLocatorByText to locatorByText --- .../actions/mobile/PageFunctions.java | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java index e27f5e2c..50dc055b 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java @@ -158,23 +158,25 @@ public static Function locatorBySelector(By by) { } /// - /// Returns a function that gives a locator of a link-like element whose text contains a given `text`. + /// Returns a function that resolves a locator whose text contains `text` in a given driver. + /// Delegates to `locatorByText(text, true)`. /// - /// @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`. + /// @param text A string to be contained by the matching element's text. + /// @return A function that resolves a locator whose text contains `text`. /// public static Function linkLocatorByText(String text) { - return linkLocatorByText(text, true); + return locatorByText(text, true); } /// - /// Returns a function that gives a locator of a link-like element whose text equals to a given `text`. + /// Returns a function that resolves a locator whose text equals `text` in a given driver. + /// Delegates to `locatorByText(text, false)`. /// - /// @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`. + /// @param text A string to be matched exactly against the matching element's text. + /// @return A function that resolves a locator whose text equals `text`. /// public static Function linkLocatorByExactText(String text) { - return linkLocatorByText(text, false); + return locatorByText(text, false); } /// @@ -185,12 +187,4 @@ public static Function linkLocatorByExactText(String t public static Function toTitle() { return Printables.function("title", AppiumDriver::getTitle); } - - public static Function linkLocatorByText(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("link:@[text" + (lenient ? "~" : "=") + text + "]", - d -> d.findElement(By.xpath(xpath))); - } } \ No newline at end of file From 0a75fa4807cfe4d43d32c1eeb9c350dda85e3aaa Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 9 Jul 2026 13:58:01 +0900 Subject: [PATCH 07/12] fix: return By from PageFuctions for act compatibility --- .../actions/mobile/PageFunctions.java | 67 ++++--- .../autotest/ut/builtins/BuiltInActsTest.java | 177 +++++++----------- 2 files changed, 109 insertions(+), 135 deletions(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java index 50dc055b..e406eafc 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java @@ -39,7 +39,7 @@ public enum PageFunctions {; /// @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 linkLocatorByName(String name) { + public static Function linkLocatorByName(String name) { return linkLocatorByName(name, false); } @@ -51,12 +51,13 @@ public static Function linkLocatorByName(String name) /// @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 linkLocatorByName(String name, boolean lenient) { + public static Function linkLocatorByName(String name, boolean lenient) { + String lit = xpathLiteral(name); String xpath = lenient - ? "//*[contains(@content-desc,'" + name + "') or contains(@name,'" + name + "')]" - : "//*[@content-desc='" + name + "' or @name='" + name + "']"; + ? "//*[contains(@content-desc," + lit + ") or contains(@name," + lit + ")]" + : "//*[@content-desc=" + lit + " or @name=" + lit + "]"; return Printables.function("link[name" + (lenient ? "~" : "=") + name + "]", - d -> d.findElement(By.xpath(xpath))); + d -> By.xpath(xpath)); } /// @@ -65,7 +66,7 @@ public static Function linkLocatorByName(String name, /// @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 locatorByText(String text) { + public static Function locatorByText(String text) { return locatorByText(text, false); } @@ -80,12 +81,13 @@ public static Function locatorByText(String text) { /// @param lenient `true` - lenient / `false` - strict. /// @return A function that resolves a locator which matches `text` in a given `AppiumDriver` object. /// - public static Function locatorByText(String text, boolean lenient) { + public static Function locatorByText(String text, boolean lenient) { + String lit = xpathLiteral(text); String xpath = lenient - ? "//*[contains(@text,'" + text + "') or contains(@label,'" + text + "') or contains(@name,'" + text + "')]" - : "//*[@text='" + text + "' or @label='" + text + "' or @name='" + text + "']"; + ? "//*[contains(@text," + lit + ") or contains(@label," + lit + ") or contains(@name," + lit + ")]" + : "//*[@text=" + lit + " or @label=" + lit + " or @name=" + lit + "]"; return Printables.function("@[text" + (lenient ? "~" : "=") + text + "]", - d -> d.findElement(By.xpath(xpath))); + d -> By.xpath(xpath)); } /// @@ -96,10 +98,10 @@ public static Function locatorByText(String text, bool /// @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 buttonLocatorByName(String name) { + public static Function buttonLocatorByName(String name) { + String lit = xpathLiteral(name); return Printables.function("@[name=" + name + "]", - d -> d.findElement(By.xpath( - "//android.widget.Button[@text='" + name + "'] | //XCUIElementTypeButton[@name='" + name + "']"))); + d -> By.xpath("//android.widget.Button[@text=" + lit + "] | //XCUIElementTypeButton[@name=" + lit + "]")); } /// @@ -108,7 +110,7 @@ public static Function buttonLocatorByName(String name /// @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 locatorByLabel(String label) { + public static Function locatorByLabel(String label) { return locatorByLabel(label, false); } @@ -124,12 +126,13 @@ public static Function locatorByLabel(String label) { /// @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 locatorByLabel(String label, boolean lenient) { + public static Function locatorByLabel(String label, boolean lenient) { + String lit = xpathLiteral(label); String xpath = lenient - ? "//*[contains(@content-desc,'" + label + "') or contains(@label,'" + label + "')]" - : "//*[@content-desc='" + label + "' or @label='" + label + "']"; + ? "//*[contains(@content-desc," + lit + ") or contains(@label," + lit + ")]" + : "//*[@content-desc=" + lit + " or @label=" + lit + "]"; return Printables.function("@[label" + (lenient ? "~" : "=") + label + "]", - d -> d.findElement(By.xpath(xpath))); + d -> By.xpath(xpath)); } /// @@ -140,10 +143,10 @@ public static Function locatorByLabel(String label, bo /// @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 locatorByPlaceholder(String placeholder) { + public static Function locatorByPlaceholder(String placeholder) { + String lit = xpathLiteral(placeholder); return Printables.function("@[placeholder=" + placeholder + "]", - d -> d.findElement(By.xpath( - "//*[@hint='" + placeholder + "' or @placeholderValue='" + placeholder + "']"))); + d -> By.xpath("//*[@hint=" + lit + " or @placeholderValue=" + lit + "]")); } /// @@ -152,7 +155,7 @@ public static Function locatorByPlaceholder(String pla /// @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 locatorBySelector(By by) { + public static Function findElementBy(By by) { Requires.requireNonNull(by); return Printables.function("@[" + by + "]", d -> d.findElement(by)); } @@ -164,7 +167,7 @@ public static Function locatorBySelector(By by) { /// @param text A string to be contained by the matching element's text. /// @return A function that resolves a locator whose text contains `text`. /// - public static Function linkLocatorByText(String text) { + public static Function linkLocatorByText(String text) { return locatorByText(text, true); } @@ -175,7 +178,7 @@ public static Function linkLocatorByText(String text) /// @param text A string to be matched exactly against the matching element's text. /// @return A function that resolves a locator whose text equals `text`. /// - public static Function linkLocatorByExactText(String text) { + public static Function linkLocatorByExactText(String text) { return locatorByText(text, false); } @@ -187,4 +190,20 @@ public static Function linkLocatorByExactText(String t public static Function toTitle() { return Printables.function("title", AppiumDriver::getTitle); } + + private static String xpathLiteral(String value) { + if (!value.contains("'")) { + return "'" + value + "'"; + } + StringBuilder sb = new StringBuilder("concat("); + String[] parts = value.split("'", -1); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + sb.append(",\"'\","); + } + sb.append("'").append(parts[i]).append("'"); + } + sb.append(")"); + return sb.toString(); + } } \ No newline at end of file diff --git a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java index cfdc7ad3..4d369b68 100644 --- a/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java +++ b/src/test/java/jp/co/moneyforward/autotest/ut/builtins/BuiltInActsTest.java @@ -9,7 +9,6 @@ import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; import jp.co.moneyforward.autotest.ututils.TestBase; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.openqa.selenium.By; import org.openqa.selenium.WebElement; @@ -385,169 +384,108 @@ void whenElementFunctionsIsEnabled_thenIsEnabledCalledOnElement() { @Test void whenPageFunctionsLinkLocatorByName_thenExactMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("@content-desc='hello'") - .containing("@name='hello'")); + assertStatement(value(by.toString()).toBe() + .containing("@content-desc='hello'") + .containing("@name='hello'")); } @Test void whenPageFunctionsLinkLocatorByNameLenient_thenContainsMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello", true).apply(null); - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello", true).apply(driver); - - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("contains(@content-desc,'hello')") - .containing("contains(@name,'hello')")); + assertStatement(value(by.toString()).toBe() + .containing("contains(@content-desc,'hello')") + .containing("contains(@name,'hello')")); } @Test void whenPageFunctionsLocatorByText_thenExactMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("@text='hello'") - .containing("@label='hello'") - .containing("@name='hello'")); + assertStatement(value(by.toString()).toBe() + .containing("@text='hello'") + .containing("@label='hello'") + .containing("@name='hello'")); } @Test void whenPageFunctionsLocatorByTextLenient_thenContainsMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello", true).apply(null); - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello", true).apply(driver); - - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("contains(@text,'hello')") - .containing("contains(@label,'hello')") - .containing("contains(@name,'hello')")); + assertStatement(value(by.toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); } @Test void whenPageFunctionsButtonLocatorByName_thenCorrectXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.buttonLocatorByName("Submit").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.buttonLocatorByName("Submit").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("android.widget.Button[@text='Submit']") - .containing("XCUIElementTypeButton[@name='Submit']")); + assertStatement(value(by.toString()).toBe() + .containing("android.widget.Button[@text='Submit']") + .containing("XCUIElementTypeButton[@name='Submit']")); } @Test void whenPageFunctionsLocatorByLabel_thenExactMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel").apply(null); - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel").apply(driver); - - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("@content-desc='myLabel'") - .containing("@label='myLabel'")); + assertStatement(value(by.toString()).toBe() + .containing("@content-desc='myLabel'") + .containing("@label='myLabel'")); } @Test void whenPageFunctionsLocatorByLabelLenient_thenContainsMatchXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel", true).apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel", true).apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("contains(@content-desc,'myLabel')") - .containing("contains(@label,'myLabel')")); + assertStatement(value(by.toString()).toBe() + .containing("contains(@content-desc,'myLabel')") + .containing("contains(@label,'myLabel')")); } @Test void whenPageFunctionsLocatorByPlaceholder_thenCorrectXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByPlaceholder("Enter name").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByPlaceholder("Enter name").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("@hint='Enter name'") - .containing("@placeholderValue='Enter name'")); + assertStatement(value(by.toString()).toBe() + .containing("@hint='Enter name'") + .containing("@placeholderValue='Enter name'")); } @Test - void whenPageFunctionsLocatorBySelector_thenFindElementCalledWithGivenBy() { + void whenPageFunctionsLocatorBySelector_thenByIsReturned() { AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); By by = By.id("targetId"); - when(driver.findElement(by)).thenReturn(element); + WebElement expected = Mockito.mock(WebElement.class); + when(driver.findElement(by)).thenReturn(expected); - WebElement result = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorBySelector(by).apply(driver); + WebElement result = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.findElementBy(by).apply(driver); - assertAll(value(result).toBe().equalTo(element)); - Mockito.verify(driver).findElement(by); + assertAll(value(result).toBe().equalTo(expected)); } @Test void whenPageFunctionsLinkLocatorByText_thenLenientXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByText("hello").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByText("hello").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("contains(@text,'hello')") - .containing("contains(@label,'hello')") - .containing("contains(@name,'hello')")); + assertStatement(value(by.toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); } @Test void whenPageFunctionsLinkLocatorByExactText_thenExactXpathUsed() { - AppiumDriver driver = Mockito.mock(AppiumDriver.class); - WebElement element = Mockito.mock(WebElement.class); - ArgumentCaptor byCaptor = ArgumentCaptor.forClass(By.class); - when(driver.findElement(any(By.class))).thenReturn(element); - - jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByExactText("hello").apply(driver); + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByExactText("hello").apply(null); - Mockito.verify(driver).findElement(byCaptor.capture()); - assertStatement(value(byCaptor.getValue().toString()).toBe() - .containing("@text='hello'") - .containing("@label='hello'") - .containing("@name='hello'")); + assertStatement(value(by.toString()).toBe() + .containing("@text='hello'") + .containing("@label='hello'") + .containing("@name='hello'")); } @Test @@ -633,4 +571,21 @@ void givenMobileSendKeyWithMaskedKey_whenName_thenNameContainsMaskPrefixNotSecre .containing(MASK_PREFIX) .not(v -> v.containing("secret"))); } + + @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)); + when(element.isDisplayed()).thenReturn(true); + + new jp.co.moneyforward.autotest.actions.mobile.Click(jp.co.moneyforward.autotest.actions.mobile.PageFunctions.buttonLocatorByName("Submit")).perform(driver, env); + new jp.co.moneyforward.autotest.actions.mobile.ClickIfPresent(jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello")).perform(driver, env); + new jp.co.moneyforward.autotest.actions.mobile.SendKey(jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByPlaceholder("Enter name"), "text").perform(driver, env); + + Mockito.verify(element, Mockito.atLeast(2)).click(); + Mockito.verify(element).sendKeys("text"); + } } From 096e8cfd0f39e3b6767039cf5b0610564abddebb Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 9 Jul 2026 15:37:42 +0900 Subject: [PATCH 08/12] feat: add MobileAct to support general-purpose inline acts --- .../autotest/actions/mobile/MobileAct.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/MobileAct.java diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/MobileAct.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/MobileAct.java new file mode 100644 index 00000000..47739329 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/MobileAct.java @@ -0,0 +1,78 @@ +package jp.co.moneyforward.autotest.actions.mobile; + +import io.appium.java_client.AppiumDriver; +import jp.co.moneyforward.autotest.framework.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; + +import java.util.function.BiConsumer; + +import static com.github.valid8j.classic.Requires.requireNonNull; + +/// +/// A general-purpose act. +/// Convenient starting point for writing **insdog** based tests. +/// +public abstract class MobileAct implements Act { + private final String description; + + /// + /// Creates a new instance of this class. + /// + /// It is advised to give a concise and descriptive string to `description` parameter as it is printed the test report. + /// The `description` should be concise but informative enough for a reader to reproduce the same action that this `Act` performs. + /// + /// @param description A string that describes this object. + /// + protected MobileAct(String description) { + this.description = requireNonNull(description); + } + + /// + /// Creates a `MobileAct with a given description and an action + /// + /// @param description A string to describe created page act. + /// @param action An action to be performed. + /// @return A page act that performs `action`. + /// + public static MobileAct mobileAct(String description, + BiConsumer action) { + return new MobileAct(description) { + @Override + protected void action(AppiumDriver driver, ExecutionEnvironment env) { + action.accept(driver, env); + } + }; + } + + /// + /// Performs an action defined for this class. + /// Its execution is delegated to `perform(AppiumDriver,ExecutionEnvironment)` method. + /// + /// @param value A page object on which this `act` is performed. + /// @param executionEnvironment An execution environment, in which this act is performed. + /// @return The `value` itself should be returned, usually. + /// + @Override + public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) { + this.action(value, executionEnvironment); + return value; + } + + /// + /// A method that defines the `act` to be performed by this object. + /// + /// @param driver A driver object on which this `act` is performed. + /// @param executionEnvironment An execution environment, in which this act is performed. + /// + protected abstract void action(AppiumDriver driver, ExecutionEnvironment executionEnvironment); + + /// + /// Returns a name of this object. + /// + /// @return A name of this object. + /// + @Override + public String name() { + return "Driver[" + this.description + "]"; + } +} From 070081ae50ff2e9816bf10780b6a3b00410c8f2d Mon Sep 17 00:00:00 2001 From: sukezan Date: Thu, 9 Jul 2026 16:40:33 +0900 Subject: [PATCH 09/12] feat: add DriverClose act to clean up Appium sessions on teardown --- .../autotest/actions/mobile/CloseDriver.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/CloseDriver.java diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/CloseDriver.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/CloseDriver.java new file mode 100644 index 00000000..b3b7eb92 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/CloseDriver.java @@ -0,0 +1,14 @@ +package jp.co.moneyforward.autotest.actions.mobile; + +import io.appium.java_client.AppiumDriver; +import jp.co.moneyforward.autotest.framework.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; +import org.openqa.selenium.By; + +public class CloseDriver implements Act { + @Override + public Void perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) { + value.quit(); + return null; + } +} From 834b9695a649822e5baf3b91bb2a0eb0ef5f9999 Mon Sep 17 00:00:00 2001 From: sukezan Date: Mon, 3 Aug 2026 17:46:58 +0900 Subject: [PATCH 10/12] feat: add isDisplayed function to elementFuncions --- .../java/jp/co/moneyforward/autotest/actions/mobile/Back.java | 4 ++++ .../autotest/actions/mobile/ElementFunctions.java | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java new file mode 100644 index 00000000..fb7d809b --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java @@ -0,0 +1,4 @@ +package jp.co.moneyforward.autotest.actions.mobile; + +public class Back { +} diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java index 31476ccc..a6c26f75 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java @@ -20,4 +20,8 @@ public static Function tagContent() { public static Function isEnabled() { return Printables.function("isEnabled", WebElement::isEnabled); } + + public static Function isDisplayed() { + return Printables.function("isDisplayed", WebElement::isDisplayed); + } } From 52d656af4b91db89b60c7ba7925f50340299e73d Mon Sep 17 00:00:00 2001 From: sukezan Date: Mon, 3 Aug 2026 17:49:42 +0900 Subject: [PATCH 11/12] feat: add action for scrolling until target element is visible --- .../actions/mobile/ScrollToElement.java | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/main/java/jp/co/moneyforward/autotest/actions/mobile/ScrollToElement.java diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ScrollToElement.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ScrollToElement.java new file mode 100644 index 00000000..8282c1ce --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ScrollToElement.java @@ -0,0 +1,122 @@ +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.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; +import jp.co.moneyforward.autotest.framework.utils.InternalUtils; +import org.openqa.selenium.By; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.PointerInput; +import org.openqa.selenium.interactions.Sequence; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import static com.github.valid8j.classic.Requires.requireNonNull; + +/// +/// An act that scrolls the screen in a given direction until a target element becomes visible. +/// +/// Performs a swipe gesture repeatedly, up to `maxScrollAttempts` times. +/// After each swipe the element's presence and visibility are checked. +/// If the element is still not visible after all attempts, a `NoSuchElementException` is thrown. +/// +public class ScrollToElement implements Act { + + /// + /// The direction in which to scroll. + /// + public enum Direction { UP, DOWN, LEFT, RIGHT } + + private static final int DEFAULT_MAX_SCROLL_ATTEMPTS = 10; + + private final Function locatorFunction; + private final Direction direction; + private final int maxScrollAttempts; + + /// + /// Creates an object of this class that scrolls downward until the element located by `by` becomes visible. + /// + /// @param by A locator for the target element. + /// + public ScrollToElement(By by) { + this(Printables.function("@" + by, d -> by)); + } + + /// + /// Creates an object of this class that scrolls downward until the element resolved by `locatorFunction` becomes visible. + /// + /// @param locatorFunction A function to locate the target element. + /// + public ScrollToElement(Function locatorFunction) { + this(locatorFunction, Direction.DOWN, DEFAULT_MAX_SCROLL_ATTEMPTS); + } + + /// + /// Creates an object of this class. + /// + /// @param by A locator for the target element. + /// @param direction The direction in which to scroll. + /// @param maxScrollAttempts The maximum number of scroll attempts before failing. + /// + public ScrollToElement(By by, Direction direction, int maxScrollAttempts) { + this(Printables.function("@" + by, d -> by), direction, maxScrollAttempts); + } + + /// + /// Creates an object of this class. + /// + /// @param locatorFunction A function to locate the target element. + /// @param direction The direction in which to scroll. + /// @param maxScrollAttempts The maximum number of scroll attempts before failing. + /// + public ScrollToElement(Function locatorFunction, Direction direction, int maxScrollAttempts) { + this.locatorFunction = requireNonNull(locatorFunction); + this.direction = requireNonNull(direction); + this.maxScrollAttempts = maxScrollAttempts; + } + + @Override + public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) { + By by = this.locatorFunction.apply(driver); + for (int attempt = 0; attempt < maxScrollAttempts; attempt++) { + List elements = driver.findElements(by); + if (!elements.isEmpty() && elements.getFirst().isDisplayed()) { + return driver; + } + swipe(driver); + } + driver.findElement(by); + return driver; + } + + @Override + public String name() { + return InternalUtils.simpleClassNameOf(this.getClass()) + "[" + this.locatorFunction + "][" + this.direction + "]"; + } + + private void swipe(AppiumDriver driver) { + Dimension size = driver.manage().window().getSize(); + int centerX = size.getWidth() / 2; + int centerY = size.getHeight() / 2; + int startX, startY, endX, endY; + switch (this.direction) { + case DOWN -> { startX = centerX; startY = (int)(size.getHeight() * 0.7); endX = centerX; endY = (int)(size.getHeight() * 0.3); } + case UP -> { startX = centerX; startY = (int)(size.getHeight() * 0.3); endX = centerX; endY = (int)(size.getHeight() * 0.7); } + case RIGHT -> { startX = (int)(size.getWidth() * 0.7); startY = centerY; endX = (int)(size.getWidth() * 0.3); endY = centerY; } + case LEFT -> { startX = (int)(size.getWidth() * 0.3); startY = centerY; endX = (int)(size.getWidth() * 0.7); endY = centerY; } + default -> throw new IllegalStateException("Unexpected direction: " + this.direction); + } + PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger"); + Sequence swipe = new Sequence(finger, 0); + swipe.addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), startX, startY)); + swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg())); + swipe.addAction(finger.createPointerMove(Duration.ofMillis(600), PointerInput.Origin.viewport(), endX, endY)); + swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg())); + driver.perform(Collections.singletonList(swipe)); + } +} \ No newline at end of file From f4d5eadc92b02050cd541ca1bf88055cfb938565 Mon Sep 17 00:00:00 2001 From: sukezan Date: Mon, 3 Aug 2026 17:51:04 +0900 Subject: [PATCH 12/12] feat: add action for device hardware back button press --- .../autotest/actions/mobile/Back.java | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java index fb7d809b..fbc581ae 100644 --- a/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java @@ -1,4 +1,80 @@ package jp.co.moneyforward.autotest.actions.mobile; -public class Back { +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.nativekey.AndroidKey; +import io.appium.java_client.android.nativekey.KeyEvent; +import io.appium.java_client.ios.IOSDriver; +import jp.co.moneyforward.autotest.framework.action.Act; +import jp.co.moneyforward.autotest.framework.core.ExecutionEnvironment; +import jp.co.moneyforward.autotest.framework.utils.InternalUtils; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.interactions.PointerInput; +import org.openqa.selenium.interactions.Sequence; + +import java.time.Duration; +import java.util.Collections; + +/// +/// An act that models a user behavior of pressing the device's back button. +/// +/// - **Android**: presses the hardware back key (`AndroidKey.BACK`, keycode 4) via `AndroidDriver.pressKey`. +/// - **iOS**: performs a left-edge swipe to trigger the interactive pop gesture (equivalent of the swipe-back navigation). +/// +public class Back implements Act { + + private final int count; + + /// + /// Creates an object of this class that presses the back button once. + /// + public Back() { + this(1); + } + + /// + /// Creates an object of this class. + /// + /// @param count The number of times to press the back button. + /// + public Back(int count) { + this.count = count; + } + + @Override + public AppiumDriver perform(AppiumDriver driver, ExecutionEnvironment executionEnvironment) { + for (int i = 0; i < count; i++) { + if (driver instanceof AndroidDriver androidDriver) { + androidDriver.pressKey(new KeyEvent(AndroidKey.BACK)); + } else if (driver instanceof IOSDriver) { + edgeSwipeBack(driver); + } else { + driver.navigate().back(); + } + } + return driver; + } + + /// + /// Returns a name of this object. + /// + /// @return A name of this object. + /// + @Override + public String name() { + return InternalUtils.simpleClassNameOf(this.getClass()) + "[x" + count + "]"; + } + + private static void edgeSwipeBack(AppiumDriver driver) { + Dimension size = driver.manage().window().getSize(); + int endX = (int)(size.getWidth() * 0.8); + int centerY = size.getHeight() / 2; + PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger"); + Sequence swipe = new Sequence(finger, 0); + swipe.addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), 0, centerY)); + swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg())); + swipe.addAction(finger.createPointerMove(Duration.ofMillis(300), PointerInput.Origin.viewport(), endX, centerY)); + swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg())); + driver.perform(Collections.singletonList(swipe)); + } }