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/Back.java b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java new file mode 100644 index 00000000..fbc581ae --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Back.java @@ -0,0 +1,80 @@ +package jp.co.moneyforward.autotest.actions.mobile; + +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)); + } +} 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..aafeae2a --- /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().isDisplayed()) { + elements.getFirst().click(); + } + return driver; + } +} 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; + } +} 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..a6c26f75 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/ElementFunctions.java @@ -0,0 +1,27 @@ +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); + } + + public static Function isDisplayed() { + return Printables.function("isDisplayed", WebElement::isDisplayed); + } +} 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 + "]"; + } +} 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..e406eafc --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/PageFunctions.java @@ -0,0 +1,209 @@ +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 lit = xpathLiteral(name); + String xpath = lenient + ? "//*[contains(@content-desc," + lit + ") or contains(@name," + lit + ")]" + : "//*[@content-desc=" + lit + " or @name=" + lit + "]"; + return Printables.function("link[name" + (lenient ? "~" : "=") + name + "]", + d -> 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 lit = xpathLiteral(text); + String xpath = lenient + ? "//*[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 -> 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) { + String lit = xpathLiteral(name); + return Printables.function("@[name=" + name + "]", + d -> By.xpath("//android.widget.Button[@text=" + lit + "] | //XCUIElementTypeButton[@name=" + lit + "]")); + } + + /// + /// 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 lit = xpathLiteral(label); + String xpath = lenient + ? "//*[contains(@content-desc," + lit + ") or contains(@label," + lit + ")]" + : "//*[@content-desc=" + lit + " or @label=" + lit + "]"; + return Printables.function("@[label" + (lenient ? "~" : "=") + label + "]", + d -> 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) { + String lit = xpathLiteral(placeholder); + return Printables.function("@[placeholder=" + placeholder + "]", + d -> By.xpath("//*[@hint=" + lit + " or @placeholderValue=" + lit + "]")); + } + + /// + /// 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 findElementBy(By by) { + Requires.requireNonNull(by); + return Printables.function("@[" + by + "]", d -> d.findElement(by)); + } + + /// + /// 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 by the matching element's text. + /// @return A function that resolves a locator whose text contains `text`. + /// + public static Function linkLocatorByText(String text) { + return locatorByText(text, true); + } + + /// + /// 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 against the matching element's text. + /// @return A function that resolves a locator whose text equals `text`. + /// + public static Function linkLocatorByExactText(String text) { + return locatorByText(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); + } + + 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/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..d4296e36 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/Screenshot.java @@ -0,0 +1,45 @@ +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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/// +/// 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) { + try { + 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); + } + return value; + } +} \ No newline at end of file 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 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..3f7df0a2 --- /dev/null +++ b/src/main/java/jp/co/moneyforward/autotest/actions/mobile/SendKey.java @@ -0,0 +1,71 @@ +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; +import static jp.co.moneyforward.autotest.framework.utils.InternalUtils.MASK_PREFIX; + +public class SendKey implements Act { + 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; + } + + @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/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 cbfff184..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 @@ -2,17 +2,25 @@ 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.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.*; +import static jp.co.moneyforward.autotest.framework.utils.InternalUtils.MASK_PREFIX; import static org.mockito.Mockito.*; class BuiltInActsTest extends TestBase { @@ -120,7 +128,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(); @@ -130,7 +138,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(); @@ -138,7 +146,7 @@ void givenMaskedString_whenName_thenNameLooksOkWithoutUnmaskedString() { assertStatement(value(name).toBe() .containing("SendKey") .not(v -> v.containing("keysToBeSentToHello")) - .containing(SendKey.MASK_PREFIX)); + .containing(MASK_PREFIX)); } @Test @@ -276,4 +284,308 @@ 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)); + when(element.isDisplayed()).thenReturn(true); + + 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() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("@content-desc='hello'") + .containing("@name='hello'")); + } + + @Test + void whenPageFunctionsLinkLocatorByNameLenient_thenContainsMatchXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByName("hello", true).apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("contains(@content-desc,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsLocatorByText_thenExactMatchXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("@text='hello'") + .containing("@label='hello'") + .containing("@name='hello'")); + } + + @Test + void whenPageFunctionsLocatorByTextLenient_thenContainsMatchXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByText("hello", true).apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsButtonLocatorByName_thenCorrectXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.buttonLocatorByName("Submit").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("android.widget.Button[@text='Submit']") + .containing("XCUIElementTypeButton[@name='Submit']")); + } + + @Test + void whenPageFunctionsLocatorByLabel_thenExactMatchXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("@content-desc='myLabel'") + .containing("@label='myLabel'")); + } + + @Test + void whenPageFunctionsLocatorByLabelLenient_thenContainsMatchXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByLabel("myLabel", true).apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("contains(@content-desc,'myLabel')") + .containing("contains(@label,'myLabel')")); + } + + @Test + void whenPageFunctionsLocatorByPlaceholder_thenCorrectXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.locatorByPlaceholder("Enter name").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("@hint='Enter name'") + .containing("@placeholderValue='Enter name'")); + } + + @Test + void whenPageFunctionsLocatorBySelector_thenByIsReturned() { + AppiumDriver driver = Mockito.mock(AppiumDriver.class); + By by = By.id("targetId"); + WebElement expected = Mockito.mock(WebElement.class); + when(driver.findElement(by)).thenReturn(expected); + + WebElement result = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.findElementBy(by).apply(driver); + + assertAll(value(result).toBe().equalTo(expected)); + } + + @Test + void whenPageFunctionsLinkLocatorByText_thenLenientXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByText("hello").apply(null); + + assertStatement(value(by.toString()).toBe() + .containing("contains(@text,'hello')") + .containing("contains(@label,'hello')") + .containing("contains(@name,'hello')")); + } + + @Test + void whenPageFunctionsLinkLocatorByExactText_thenExactXpathUsed() { + By by = jp.co.moneyforward.autotest.actions.mobile.PageFunctions.linkLocatorByExactText("hello").apply(null); + + assertStatement(value(by.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"); + 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), + value(destPath.toFile().exists()).toBe().equalTo(true)); + 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, MASK_PREFIX + "myPassword" + ).perform(driver, executionEnvironment); + + assertAll(value(returned).toBe().equalTo(driver)); + Mockito.verify(element).sendKeys("myPassword"); + } + + @Test + 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(); + + 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"))); + } + + @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"); + } }