Skip to content

feat: Develop actions for mobile - #10

Open
sukezan wants to merge 12 commits into
mainfrom
develop-autotest-for-mobile
Open

feat: Develop actions for mobile#10
sukezan wants to merge 12 commits into
mainfrom
develop-autotest-for-mobile

Conversation

@sukezan

@sukezan sukezan commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

This Pull Request adds actions for mobile and introduces capabilities to support mobile testing

Changes:

  • Add mobile-specific actions compatible with AppiumDriver under actions/mobile
  • Add unit tests to BuiltInActsTest for the newly introduced actions
  • Add java-client (Appium) to dependencies

Verification:

  • build succeeds
  • each action works as expected within the created automated test suites.

@dakusui dakusui left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review summary

This PR adds an Appium-based actions/mobile package mirroring the existing Playwright actions/web package, plus the io.appium:java-client:10.1.1 dependency and unit tests. The dependency itself checks out (version exists on Maven Central; no Selenium version conflict with this repo).

The main theme of the findings: the port is a surface copy of the web package — several load-bearing design properties of the web version didn't survive the translation. Inline comments below, most severe first:

  1. PageFunctions cannot feed the acts — every method returns Function<AppiumDriver, WebElement>, but Click/ClickIfPresent/SendKey accept only Function<AppiumDriver, By>; also resolves eagerly, unlike the lazy web Locator design. (PageFunctions.java)
  2. XPath built by string concatenation breaks on single quotes in all six locator builders. (PageFunctions.java)
  3. ClickIfPresent drops the web version's visibility guard — clicks on mere presence. (ClickIfPresent.java)
  4. Files.copy may hit a missing parent directory — the framework doesn't reliably pre-create the test-result dir for the main stage. (Screenshot.java)
  5. MASK_PREFIX redefined while InternalUtils.mask() is bound to the web constant — the two can drift. (SendKey.java)
  6. No name() override on SendKey — action trees lose the target locator. (SendKey.java)
  7. linkLocatorByText builds byte-identical XPath to locatorByText and doesn't restrict to link-like elements despite its Javadoc. (PageFunctions.java)
  8. Screenshot: per-call temp-file leak + double write, and a Path→String→File→Path round-trip. (Screenshot.java)

Minor (no inline comments): PageFunctions/ElementFunctions use 4-space indent vs the 2-space house style; ElementFunctions declares a redundant explicit private constructor in an enum; several new files lack trailing newlines; MASK_PREFIX's doc comment is a truncated sentence ("A prefix to control a").

Bottom line: the act classes themselves are reasonable, but findings 1–2 make PageFunctions effectively unusable as shipped. I'd suggest the Function<AppiumDriver, By> redesign plus the visibility/directory fixes before merge.

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

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

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

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

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

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

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

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

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

🤖 Generated with Claude Code

///
public static Function<AppiumDriver, WebElement> linkLocatorByName(String name, boolean lenient) {
String xpath = lenient
? "//*[contains(@content-desc,'" + name + "') or contains(@name,'" + name + "')]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Suggestion:

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

public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) {
File screenshot = value.getScreenshotAs(OutputType.FILE);
try {
Files.copy(screenshot.toPath(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[4] Files.copy does not create the destination's parent directory — and the framework doesn't guarantee it exists.

ExecutionEnvironment.testOutputFilenameFor only builds a Path (no mkdirs). The only directory creation is in AutotestEngine.configureLogging, which covers the before/after stages, but the main stage builds its ExecutionEnvironment with a differently-derived display name (AutotestEngine.java:331 vs :174), so the directory can be missing when this act runs → NoSuchFileExceptionRuntimeException aborts the step. The web Screenshot never hits this because Playwright's setPath auto-creates parent directories. The unit test masks the issue by mocking the destination to an already-existing temp file.

Suggestion: Files.createDirectories(destination.getParent()) before the copy.

Also, minor: testOutputFilenameFor(...) already returns a java.nio.file.Path — the new File(String.valueOf(...)).toPath() round-trip can be dropped and the Path passed to Files.copy directly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Files.mkdirsだったかな?参考にするといいかも

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

ディレクトリ作成にはFiles.mkdirsFiles.createDirectoriesがあるようですが、どちらを採用するのがいいと思いますか?個人的には複数のexceptionを投げるcreateDirectoriesの方が良いように思えるのですが...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

上のように書いちゃいましたがFile.mkdirsはレガシーですね、Files.createDirectoriesの方がいいと思います。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

こちらのcommitでFiles.createDirectoriesを導入するように修正しました。

///
@Override
public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment executionEnvironment) {
File screenshot = value.getScreenshotAs(OutputType.FILE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[8] Per-screenshot temp-file leak and double write.

getScreenshotAs(OutputType.FILE) writes the image to a Selenium-created temp file that is deleted only on JVM exit, and nothing deletes it here after the copy — one leaked temp file per screenshot, at every beforeAll/beforeEach/afterEach/afterAll across a long session, plus a redundant second disk write.

Suggestion: Files.write(destination, value.getScreenshotAs(OutputType.BYTES)) — one write, no temp file.

///
/// A prefix to control a
///
public static final String MASK_PREFIX = "MASK!";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[5] MASK_PREFIX is redefined here, but the framework's masking is bound to the web constant.

InternalUtils.java:34 does import static jp.co.moneyforward.autotest.actions.web.SendKey.MASK_PREFIX; and uses it in mask() (InternalUtils.java:150). With two independent "MASK!" literals, a future change to either silently breaks the other: secrets in mobile flows would stop being masked by framework-level logging while web flows stay masked.

Suggestion: reference one shared constant (e.g. move MASK_PREFIX to a neutral home like InternalUtils and have both SendKey classes point at it, or have this class reference web.SendKey.MASK_PREFIX).


import static com.github.valid8j.classic.Requires.requireNonNull;

public class SendKey implements Act<AppiumDriver, AppiumDriver> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[6] Missing name() override — action trees lose the target locator.

The web SendKey overrides name() to print SendKey[locator][MASK!|keys], and the sibling ClickBase in this package also overrides name(). This class falls back to the default Act.name(), so a failing step renders as bare SendKey with no indication of which field was targeted (and the documented "MASK_PREFIX is printed in the log" behavior is non-functional — though, to be clear, nothing leaks).

Suggestion: port the web name() override, including its masking branch.

return Printables.function("title", AppiumDriver::getTitle);
}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

@dakusui

dakusui commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up: gaps vs. the actions/web package + suggested next steps

Separate from the line-level findings — this is a roadmap / scope note, not a blocker. Keeping web and mobile as two parallel class sets (rather than abstracting a shared layer) is the right call for now; these are just the pieces the web side has that mobile doesn't yet, and the mobile-only capabilities worth planning for.

Part A — missing ports from the web side (ranked by value)

1. PageAct → introduce an AppiumDriverAct (highest priority).
Web's PageAct is the general-purpose escape hatch — its Javadoc calls it "a general-purpose act, convenient starting point for writing insdog-based tests." Mobile has no equivalent, so a mobile test today can only do the four concrete acts (Click / ClickIfPresent / SendKey / Screenshot); anything else has nowhere to live. It's a near-mechanical retype of PageAct (PageAppiumDriver):

public abstract class AppiumDriverAct implements Act<AppiumDriver, AppiumDriver> {
  private final String description;

  protected AppiumDriverAct(String description) {
    this.description = requireNonNull(description);
  }

  public static AppiumDriverAct appiumDriverAct(String description,
                                                BiConsumer<AppiumDriver, ExecutionEnvironment> action) {
    return new AppiumDriverAct(description) {
      @Override
      protected void action(AppiumDriver driver, ExecutionEnvironment env) {
        action.accept(driver, env);
      }
    };
  }

  @Override
  public AppiumDriver perform(AppiumDriver value, ExecutionEnvironment env) {
    this.action(value, env);
    return value;
  }

  protected abstract void action(AppiumDriver driver, ExecutionEnvironment env);

  @Override
  public String name() {
    return "Driver[" + this.description + "]";
  }
}

Two reasons this is the right first thing:

  • It unblocks the entire Part B list below — gestures, waits, context switches, etc. can be written inline as appiumDriverAct("swipe up", (d, env) -> { ... }) before anyone builds dedicated acts for them.
  • It's a pragmatic interim bridge around the PageFunctions ↔ act currency issue (finding [1]): a user can drop into appiumDriverAct(...) and call driver.findElement(...) directly while that redesign lands.

(Naming: AppiumDriverAct mirrors PageAct; MobileAct reads better at call sites and matches the package name. Author's pick — either is fine.)

2. Sub-element composition is silently gone. Web LocatorFunctions returns Function<Locator, Locator> — composable narrowers you chain, e.g. locatorBySelector("#sidebar").andThen(byText(item)) ("the element with this text under that container"), which the web docs advertise as the composition pattern. Mobile's ElementFunctions is not that analog — it returns Function<WebElement, String/Boolean>, i.e. readers (getText/getTagName/isEnabled). So mobile has no relative-locator narrowing at all. Same root cause as finding [1]: once PageFunctions resolves eagerly to a WebElement, there's nothing left to compose against. Fixing the currency to By-producing functions is what reopens this.

3. A Navigate analog — "get to the screen under test." Web has page.navigate(url); mobile has no in-package way to launch an app / start an activity (Android) / open a deep link.

4. A teardown act. Web has CloseBrowser + CloseWindow; mobile has nothing to driver.quit() / terminateApp(). Leaked Appium sessions tie up the server and the device/emulator across a suite. Note: because AppiumDriver fuses Playwright's Playwright/Browser/Page into one object, mobile needs only one teardown act, not two.

Lower priority / don't port verbatim:

  • TableQuery is HTML-<table>-specific — not meaningful on native mobile; skip.
  • StoreStorageState (cookies/localStorage) has only a weak mobile analog; defer.
  • Value is driver-agnostic (Act<V,V>, no Playwright import) — mobile should reuse it, not copy it. (Arguably it and the generic framework/action/Wait don't belong under actions/web at all.)

Part B — mobile-only capabilities web never needed

No web counterpart because Playwright handles them implicitly or they're physically device-only:

  • Gestures — tap, swipe / scroll-to-element, long-press, drag. This is the reason to use Appium; a control you can't reach without scrolling is unreachable today. Highest-value net-new act.
  • Explicit element waits — Playwright auto-waits before every action, so the web package never needed one; Appium/Selenium does not auto-wait, so mobile flakes without explicit wait-for-visible/clickable. Worth checking whether the existing framework/action/Wait covers element-state conditions or a mobile WaitForElement is needed.
  • App lifecycle — launch / terminate / activate / background / reset app.
  • Native ↔ WebView context switching — for hybrid apps (driver.context(...)); no web analog.
  • Device / system — orientation, hideKeyboard() (a classic gotcha right after SendKey), hardware Back/Home keys.

🤖 Generated with Claude Code

@dakusui

dakusui commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

基本的にはこのPRの範囲外だと思いますが:

  1. 実例やドキュメントを用意すると、そこから、エージェントがテストを作ってくれるようにできると思うので取り組んでみるといいと思います。これらの実例やドキュメント自体もエージェントの支援があれば効率よく作れると思います。
  2. AGENTS.mdとかを用意すると今後の機能拡張がスムーズになると思います。

もっとも”Hello, world"レベルのモバイル用の実例はこのPRに含められると嬉しいだろうなと思います。(SUTをどうしよう?と言うのはありますが)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants