Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
#TESTAR v2.7.16 (28-Nov-2025)
- Add AndroidCapabilitiesFactory for creating appium capabilities
- Implement AndroidRoles
- Refactor Android click and type actions
- Add xpath action fallback to sendKeysTextTextElementById
- Create DummyReportManager to avoid null exceptions
- Remove unused NativeLinker methods
- Update verdict info escape in html report
- Add deriveActionsFunction to android spy mode
- Make android spy info panel scrollable


#TESTAR v2.7.15 (3-Nov-2025)
- Fix WdRootElement parent
- Improve WdElement isDisplayed logic
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.7.15
2.7.16
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/***************************************************************************************************
*
* Copyright (c) 2020 - 2024 Universitat Politecnica de Valencia - www.upv.es
* Copyright (c) 2020 - 2024 Open Universiteit - www.ou.nl
* Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es
* Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
Expand Down Expand Up @@ -31,8 +31,6 @@
package org.testar.monkey.alayer.android;

import com.google.common.collect.ImmutableMap;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.testar.serialisation.ScreenshotSerialiser;

import io.appium.java_client.AppiumBy;
Expand Down Expand Up @@ -127,9 +125,13 @@ public static AndroidAppiumFramework fromCapabilities(String capabilitesJsonFile
androidSUT.stop();
}

DesiredCapabilities cap = createCapabilitiesFromJsonFile(capabilitesJsonFile);
AndroidCapabilitiesFactory factory = new AndroidCapabilitiesFactory(androidAppiumURL);

return new AndroidAppiumFramework(cap);
AndroidCapabilitiesFactory.Result result = factory.fromJsonFile(capabilitesJsonFile);

androidAppiumURL = result.getAppiumServerUrl();

return new AndroidAppiumFramework(result.getCapabilities());
}

public static AndroidDriver getDriver() {
Expand All @@ -141,42 +143,38 @@ public static List<WebElement> findElements(By by){
}

/**
* Send Click Action.
* Uses unique accessibility ID if present, otherwise uses xpath.
* Obtain the widget associated with the (Android) web element.
* Uses unique accessibility ID if present and unique, otherwise uses xpath.
*
* @param id
* @param w
* @return android web element
*/
public static void clickElementById(String id, Widget w){
if (!id.equals("")) {
driver.findElement(new AppiumBy.ByAccessibilityId(id)).click();
}
else {
String xpathString = w.get(AndroidTags.AndroidXpath);
driver.findElement(new By.ByXPath(xpathString)).click();
public static WebElement resolveElementByIdOrXPath(String id, Widget w) {
if (id != null && !id.isEmpty()) {
// Try by accessibility id only if non-null and non-empty
List<WebElement> elements = driver.findElements(new AppiumBy.ByAccessibilityId(id));

// Use the ID only if exactly one element is found
if (elements.size() == 1) {
return elements.get(0);
}
}

// Fallback using XPath: ID is empty or did not resolve to exactly one element
return AndroidAppiumFramework.resolveElementByXPath(w);
}

/**
* Send Type Action.
* Uses unique accessibility ID if present, otherwise uses xpath.
* Obtain the widget associated with the (Android) web element.
* Uses the xpath.
*
* @param id
* @param text
* @param w
* @return android web element
*/
public static void sendKeysTextTextElementById(String id, String text, Widget w){
if (!id.equals("")) {
WebElement element = driver.findElement(new AppiumBy.ByAccessibilityId(id));
element.clear();
element.sendKeys(text);
}
else {
String xpathString = w.get(AndroidTags.AndroidXpath);
WebElement element = driver.findElement(new By.ByXPath(xpathString));
element.clear();
element.sendKeys(text);
}
public static WebElement resolveElementByXPath(Widget w) {
String xpathString = w.get(AndroidTags.AndroidXpath);
return driver.findElement(new By.ByXPath(xpathString));
}

public static void scrollElementById(String id, Widget w, int scrollDistance) {
Expand Down Expand Up @@ -536,49 +534,4 @@ public static List<SUT> fromAll() {
return Collections.singletonList(androidSUT);
}

private static DesiredCapabilities createCapabilitiesFromJsonFile(String capabilitesJsonFile) {
DesiredCapabilities cap = new DesiredCapabilities();

try (FileReader reader = new FileReader(capabilitesJsonFile)) {

JsonObject jsonObject = new JsonParser().parse(reader).getAsJsonObject();

// https://appium.io/docs/en/2.0/guides/caps/
cap.setCapability("platformName", jsonObject.get("platformName").getAsString());

cap.setCapability("appium:deviceName", jsonObject.get("deviceName").getAsString());
cap.setCapability("appium:automationName", jsonObject.get("automationName").getAsString());
cap.setCapability("appium:newCommandTimeout", jsonObject.get("newCommandTimeout").getAsInt());
cap.setCapability("appium:autoGrantPermissions", jsonObject.get("autoGrantPermissions").getAsBoolean());

// TODO: Check and test next capabilities
// cap.setCapability("allowTestPackages", true);
// cap.setCapability("appWaitActivity", jsonObject.get("appWaitActivity").getAsString());

String appPath = jsonObject.get("app").getAsString();

// If emulator is running inside a docker use the APK raw URL
if(jsonObject.get("isEmulatorDocker") != null
&& jsonObject.get("ipAddressAppium") != null
&& jsonObject.get("isEmulatorDocker").getAsBoolean()) {

cap.setCapability("appium:app", appPath);

// Docker container (budtmo/docker-android) + Appium v2 do not use /wd/hub suffix anymore
// It can be enabled using the APPIUM_ADDITIONAL_ARGS "--base-path /wd/hub" command
androidAppiumURL = "http://" + jsonObject.get("ipAddressAppium").getAsString() + ":4723/wd/hub";
}
// Else, obtain the local directory that contains the APK file
else {
cap.setCapability("appium:app", new File(appPath).getCanonicalPath());
}

} catch (IOException | NullPointerException e) {
System.err.println("ERROR: Exception reading Appium Desired Capabilities from JSON file: " + capabilitesJsonFile);
e.printStackTrace();
}

return cap;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/***************************************************************************************************
*
* Copyright (c) 2025 Universitat Politecnica de Valencia - www.upv.es
* Copyright (c) 2025 Open Universiteit - www.ou.nl
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************************************/

package org.testar.monkey.alayer.android;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.openqa.selenium.remote.DesiredCapabilities;

import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.Objects;

/**
* Factory responsible for creating Appium DesiredCapabilities
* and determining the Appium server URL from a JSON configuration.
*/
public class AndroidCapabilitiesFactory {

private final String defaultAppiumUrl;

/**
* @param defaultAppiumUrl the URL that will be used when the JSON does not override it.
*/
public AndroidCapabilitiesFactory(String defaultAppiumUrl) {
this.defaultAppiumUrl = Objects.requireNonNull(defaultAppiumUrl);
}

public Result fromJsonFile(String capabilitiesJsonFile) {
try (FileReader reader = new FileReader(capabilitiesJsonFile)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
return fromJsonObject(json);
} catch (IOException | IllegalStateException e) {
System.err.println("ERROR: Exception reading Appium Desired Capabilities from JSON file: " + capabilitiesJsonFile);
e.printStackTrace();

// Preserve previous behaviour: return empty capabilities and keep the default URL.
return new Result(new DesiredCapabilities(), defaultAppiumUrl);
}
}

Result fromJsonObject(JsonObject json) {
DesiredCapabilities cap = new DesiredCapabilities();

// https://appium.io/docs/en/2.0/guides/caps/
cap.setCapability("platformName", getString(json, "platformName", "Android"));

cap.setCapability("appium:deviceName", getString(json, "deviceName", "Android Emulator"));
cap.setCapability("appium:automationName", getString(json, "automationName", "UiAutomator2"));
cap.setCapability("appium:newCommandTimeout", getInt(json, "newCommandTimeout", 600));
cap.setCapability("appium:autoGrantPermissions", getBool(json, "autoGrantPermissions", false));

String appiumUrl = defaultAppiumUrl;

// If the APK is already installed we use appPackage identifier
if (getBool(json, "isApkInstalled", false)) {
String appPackage = getString(json, "appPackage", null);
String appActivity = getString(json, "appActivity", null);

if (appPackage == null || appPackage.isEmpty()) {
throw new IllegalArgumentException("When isApkInstalled=true, 'appPackage' is required.");
}
if (appActivity == null || appActivity.isEmpty()) {
throw new IllegalArgumentException(String.join("\n",
"When isApkInstalled=true, 'appActivity' is required (multiple launcher activities can exist).",
"",
"How to find it on Windows:",
"1) Manually open the app on the emulator.",
"2) Run:",
" adb shell dumpsys activity activities | findstr /R /C:\"ResumedActivity\" /C:\"topResumedActivity\"",
"3) From the output, take the activity after the package name."
));
}

cap.setCapability("appium:appPackage", appPackage);
cap.setCapability("appium:appActivity", appActivity);
} else {
// Else we need to install the APK
String appPath = getString(json, "app", null);
if (appPath == null || appPath.isEmpty()) {
throw new IllegalArgumentException(
"When isApkInstalled=false, 'app' (APK path or URL) must be provided.");
}

boolean isEmulatorDocker = getBool(json, "isEmulatorDocker", false);
String ipAddressAppium = getString(json, "ipAddressAppium", null);

// If emulator is running inside a docker use the APK raw URL
if (isEmulatorDocker && ipAddressAppium != null && !ipAddressAppium.isEmpty()) {
// Docker container (budtmo/docker-android) + Appium v2 do not use /wd/hub suffix anymore
// It can be enabled using the APPIUM_ADDITIONAL_ARGS "--base-path /wd/hub" command
cap.setCapability("appium:app", appPath);
appiumUrl = "http://" + ipAddressAppium + ":4723/wd/hub";
} else {
// Else, obtain the local directory that contains the APK file
try {
cap.setCapability("appium:app", new File(appPath).getCanonicalPath());
} catch (IOException e) {
System.err.println("ERROR: Cannot resolve canonical path for APK: " + appPath);
e.printStackTrace();
cap.setCapability("appium:app", appPath);
}
}
}

return new Result(cap, appiumUrl);
}

private static String getString(JsonObject json, String key, String def) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : def;
}

private static boolean getBool(JsonObject json, String key, boolean def) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsBoolean() : def;
}

private static int getInt(JsonObject json, String key, int def) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsInt() : def;
}

/**
* Value object returned by the factory so callers can configure both
* the driver capabilities and the Appium server URL.
*/
public static final class Result {
private final DesiredCapabilities capabilities;
private final String appiumServerUrl;

Result(DesiredCapabilities capabilities, String appiumServerUrl) {
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
this.appiumServerUrl = Objects.requireNonNull(appiumServerUrl, "appiumServerUrl");
}

public DesiredCapabilities getCapabilities() {
return capabilities;
}

public String getAppiumServerUrl() {
return appiumServerUrl;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/***************************************************************************************************
*
* Copyright (c) 2020 - 2022 Universitat Politecnica de Valencia - www.upv.es
* Copyright (c) 2020 - 2022 Open Universiteit - www.ou.nl
* Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es
* Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
Expand Down Expand Up @@ -134,7 +134,7 @@ else if (w.element == null || w.tags.containsKey(t)) {
ret = w.element.className;
}
else if (t.equals(Tags.Role)) {
ret = AndroidRoles.AndroidWidget;
ret = AndroidRoles.fromTypeId(w.element.className);
}
else if (t.equals(Tags.HitTester)) {
ret = new org.testar.monkey.alayer.android.AndroidHitTester(w.element);
Expand Down
Loading