Skip to content

Commit 1e8c9c1

Browse files
authored
Merge pull request #458 from TESTARtool/master_android
Update android framework and default report manager
2 parents f0f833f + 57f7a2b commit 1e8c9c1

File tree

43 files changed

+1420
-445
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1420
-445
lines changed

CHANGELOG

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
#TESTAR v2.7.16 (28-Nov-2025)
2+
- Add AndroidCapabilitiesFactory for creating appium capabilities
3+
- Implement AndroidRoles
4+
- Refactor Android click and type actions
5+
- Add xpath action fallback to sendKeysTextTextElementById
6+
- Create DummyReportManager to avoid null exceptions
7+
- Remove unused NativeLinker methods
8+
- Update verdict info escape in html report
9+
- Add deriveActionsFunction to android spy mode
10+
- Make android spy info panel scrollable
11+
12+
113
#TESTAR v2.7.15 (3-Nov-2025)
214
- Fix WdRootElement parent
315
- Improve WdElement isDisplayed logic

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.7.15
1+
2.7.16

android/src/org/testar/monkey/alayer/android/AndroidAppiumFramework.java

Lines changed: 29 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/***************************************************************************************************
22
*
3-
* Copyright (c) 2020 - 2024 Universitat Politecnica de Valencia - www.upv.es
4-
* Copyright (c) 2020 - 2024 Open Universiteit - www.ou.nl
3+
* Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es
4+
* Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl
55
*
66
* Redistribution and use in source and binary forms, with or without
77
* modification, are permitted provided that the following conditions are met:
@@ -31,8 +31,6 @@
3131
package org.testar.monkey.alayer.android;
3232

3333
import com.google.common.collect.ImmutableMap;
34-
import com.google.gson.JsonObject;
35-
import com.google.gson.JsonParser;
3634
import org.testar.serialisation.ScreenshotSerialiser;
3735

3836
import io.appium.java_client.AppiumBy;
@@ -127,9 +125,13 @@ public static AndroidAppiumFramework fromCapabilities(String capabilitesJsonFile
127125
androidSUT.stop();
128126
}
129127

130-
DesiredCapabilities cap = createCapabilitiesFromJsonFile(capabilitesJsonFile);
128+
AndroidCapabilitiesFactory factory = new AndroidCapabilitiesFactory(androidAppiumURL);
131129

132-
return new AndroidAppiumFramework(cap);
130+
AndroidCapabilitiesFactory.Result result = factory.fromJsonFile(capabilitesJsonFile);
131+
132+
androidAppiumURL = result.getAppiumServerUrl();
133+
134+
return new AndroidAppiumFramework(result.getCapabilities());
133135
}
134136

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

143145
/**
144-
* Send Click Action.
145-
* Uses unique accessibility ID if present, otherwise uses xpath.
146+
* Obtain the widget associated with the (Android) web element.
147+
* Uses unique accessibility ID if present and unique, otherwise uses xpath.
146148
*
147149
* @param id
148150
* @param w
151+
* @return android web element
149152
*/
150-
public static void clickElementById(String id, Widget w){
151-
if (!id.equals("")) {
152-
driver.findElement(new AppiumBy.ByAccessibilityId(id)).click();
153-
}
154-
else {
155-
String xpathString = w.get(AndroidTags.AndroidXpath);
156-
driver.findElement(new By.ByXPath(xpathString)).click();
153+
public static WebElement resolveElementByIdOrXPath(String id, Widget w) {
154+
if (id != null && !id.isEmpty()) {
155+
// Try by accessibility id only if non-null and non-empty
156+
List<WebElement> elements = driver.findElements(new AppiumBy.ByAccessibilityId(id));
157+
158+
// Use the ID only if exactly one element is found
159+
if (elements.size() == 1) {
160+
return elements.get(0);
161+
}
157162
}
163+
164+
// Fallback using XPath: ID is empty or did not resolve to exactly one element
165+
return AndroidAppiumFramework.resolveElementByXPath(w);
158166
}
159167

160168
/**
161-
* Send Type Action.
162-
* Uses unique accessibility ID if present, otherwise uses xpath.
169+
* Obtain the widget associated with the (Android) web element.
170+
* Uses the xpath.
163171
*
164-
* @param id
165-
* @param text
166172
* @param w
173+
* @return android web element
167174
*/
168-
public static void sendKeysTextTextElementById(String id, String text, Widget w){
169-
if (!id.equals("")) {
170-
WebElement element = driver.findElement(new AppiumBy.ByAccessibilityId(id));
171-
element.clear();
172-
element.sendKeys(text);
173-
}
174-
else {
175-
String xpathString = w.get(AndroidTags.AndroidXpath);
176-
WebElement element = driver.findElement(new By.ByXPath(xpathString));
177-
element.clear();
178-
element.sendKeys(text);
179-
}
175+
public static WebElement resolveElementByXPath(Widget w) {
176+
String xpathString = w.get(AndroidTags.AndroidXpath);
177+
return driver.findElement(new By.ByXPath(xpathString));
180178
}
181179

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

539-
private static DesiredCapabilities createCapabilitiesFromJsonFile(String capabilitesJsonFile) {
540-
DesiredCapabilities cap = new DesiredCapabilities();
541-
542-
try (FileReader reader = new FileReader(capabilitesJsonFile)) {
543-
544-
JsonObject jsonObject = new JsonParser().parse(reader).getAsJsonObject();
545-
546-
// https://appium.io/docs/en/2.0/guides/caps/
547-
cap.setCapability("platformName", jsonObject.get("platformName").getAsString());
548-
549-
cap.setCapability("appium:deviceName", jsonObject.get("deviceName").getAsString());
550-
cap.setCapability("appium:automationName", jsonObject.get("automationName").getAsString());
551-
cap.setCapability("appium:newCommandTimeout", jsonObject.get("newCommandTimeout").getAsInt());
552-
cap.setCapability("appium:autoGrantPermissions", jsonObject.get("autoGrantPermissions").getAsBoolean());
553-
554-
// TODO: Check and test next capabilities
555-
// cap.setCapability("allowTestPackages", true);
556-
// cap.setCapability("appWaitActivity", jsonObject.get("appWaitActivity").getAsString());
557-
558-
String appPath = jsonObject.get("app").getAsString();
559-
560-
// If emulator is running inside a docker use the APK raw URL
561-
if(jsonObject.get("isEmulatorDocker") != null
562-
&& jsonObject.get("ipAddressAppium") != null
563-
&& jsonObject.get("isEmulatorDocker").getAsBoolean()) {
564-
565-
cap.setCapability("appium:app", appPath);
566-
567-
// Docker container (budtmo/docker-android) + Appium v2 do not use /wd/hub suffix anymore
568-
// It can be enabled using the APPIUM_ADDITIONAL_ARGS "--base-path /wd/hub" command
569-
androidAppiumURL = "http://" + jsonObject.get("ipAddressAppium").getAsString() + ":4723/wd/hub";
570-
}
571-
// Else, obtain the local directory that contains the APK file
572-
else {
573-
cap.setCapability("appium:app", new File(appPath).getCanonicalPath());
574-
}
575-
576-
} catch (IOException | NullPointerException e) {
577-
System.err.println("ERROR: Exception reading Appium Desired Capabilities from JSON file: " + capabilitesJsonFile);
578-
e.printStackTrace();
579-
}
580-
581-
return cap;
582-
}
583-
584537
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/***************************************************************************************************
2+
*
3+
* Copyright (c) 2025 Universitat Politecnica de Valencia - www.upv.es
4+
* Copyright (c) 2025 Open Universiteit - www.ou.nl
5+
*
6+
* Redistribution and use in source and binary forms, with or without
7+
* modification, are permitted provided that the following conditions are met:
8+
*
9+
* 1. Redistributions of source code must retain the above copyright notice,
10+
* this list of conditions and the following disclaimer.
11+
* 2. Redistributions in binary form must reproduce the above copyright
12+
* notice, this list of conditions and the following disclaimer in the
13+
* documentation and/or other materials provided with the distribution.
14+
* 3. Neither the name of the copyright holder nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28+
* POSSIBILITY OF SUCH DAMAGE.
29+
*******************************************************************************************************/
30+
31+
package org.testar.monkey.alayer.android;
32+
33+
import com.google.gson.JsonObject;
34+
import com.google.gson.JsonParser;
35+
import org.openqa.selenium.remote.DesiredCapabilities;
36+
37+
import java.io.File;
38+
import java.io.FileReader;
39+
import java.io.IOException;
40+
import java.util.Objects;
41+
42+
/**
43+
* Factory responsible for creating Appium DesiredCapabilities
44+
* and determining the Appium server URL from a JSON configuration.
45+
*/
46+
public class AndroidCapabilitiesFactory {
47+
48+
private final String defaultAppiumUrl;
49+
50+
/**
51+
* @param defaultAppiumUrl the URL that will be used when the JSON does not override it.
52+
*/
53+
public AndroidCapabilitiesFactory(String defaultAppiumUrl) {
54+
this.defaultAppiumUrl = Objects.requireNonNull(defaultAppiumUrl);
55+
}
56+
57+
public Result fromJsonFile(String capabilitiesJsonFile) {
58+
try (FileReader reader = new FileReader(capabilitiesJsonFile)) {
59+
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
60+
return fromJsonObject(json);
61+
} catch (IOException | IllegalStateException e) {
62+
System.err.println("ERROR: Exception reading Appium Desired Capabilities from JSON file: " + capabilitiesJsonFile);
63+
e.printStackTrace();
64+
65+
// Preserve previous behaviour: return empty capabilities and keep the default URL.
66+
return new Result(new DesiredCapabilities(), defaultAppiumUrl);
67+
}
68+
}
69+
70+
Result fromJsonObject(JsonObject json) {
71+
DesiredCapabilities cap = new DesiredCapabilities();
72+
73+
// https://appium.io/docs/en/2.0/guides/caps/
74+
cap.setCapability("platformName", getString(json, "platformName", "Android"));
75+
76+
cap.setCapability("appium:deviceName", getString(json, "deviceName", "Android Emulator"));
77+
cap.setCapability("appium:automationName", getString(json, "automationName", "UiAutomator2"));
78+
cap.setCapability("appium:newCommandTimeout", getInt(json, "newCommandTimeout", 600));
79+
cap.setCapability("appium:autoGrantPermissions", getBool(json, "autoGrantPermissions", false));
80+
81+
String appiumUrl = defaultAppiumUrl;
82+
83+
// If the APK is already installed we use appPackage identifier
84+
if (getBool(json, "isApkInstalled", false)) {
85+
String appPackage = getString(json, "appPackage", null);
86+
String appActivity = getString(json, "appActivity", null);
87+
88+
if (appPackage == null || appPackage.isEmpty()) {
89+
throw new IllegalArgumentException("When isApkInstalled=true, 'appPackage' is required.");
90+
}
91+
if (appActivity == null || appActivity.isEmpty()) {
92+
throw new IllegalArgumentException(String.join("\n",
93+
"When isApkInstalled=true, 'appActivity' is required (multiple launcher activities can exist).",
94+
"",
95+
"How to find it on Windows:",
96+
"1) Manually open the app on the emulator.",
97+
"2) Run:",
98+
" adb shell dumpsys activity activities | findstr /R /C:\"ResumedActivity\" /C:\"topResumedActivity\"",
99+
"3) From the output, take the activity after the package name."
100+
));
101+
}
102+
103+
cap.setCapability("appium:appPackage", appPackage);
104+
cap.setCapability("appium:appActivity", appActivity);
105+
} else {
106+
// Else we need to install the APK
107+
String appPath = getString(json, "app", null);
108+
if (appPath == null || appPath.isEmpty()) {
109+
throw new IllegalArgumentException(
110+
"When isApkInstalled=false, 'app' (APK path or URL) must be provided.");
111+
}
112+
113+
boolean isEmulatorDocker = getBool(json, "isEmulatorDocker", false);
114+
String ipAddressAppium = getString(json, "ipAddressAppium", null);
115+
116+
// If emulator is running inside a docker use the APK raw URL
117+
if (isEmulatorDocker && ipAddressAppium != null && !ipAddressAppium.isEmpty()) {
118+
// Docker container (budtmo/docker-android) + Appium v2 do not use /wd/hub suffix anymore
119+
// It can be enabled using the APPIUM_ADDITIONAL_ARGS "--base-path /wd/hub" command
120+
cap.setCapability("appium:app", appPath);
121+
appiumUrl = "http://" + ipAddressAppium + ":4723/wd/hub";
122+
} else {
123+
// Else, obtain the local directory that contains the APK file
124+
try {
125+
cap.setCapability("appium:app", new File(appPath).getCanonicalPath());
126+
} catch (IOException e) {
127+
System.err.println("ERROR: Cannot resolve canonical path for APK: " + appPath);
128+
e.printStackTrace();
129+
cap.setCapability("appium:app", appPath);
130+
}
131+
}
132+
}
133+
134+
return new Result(cap, appiumUrl);
135+
}
136+
137+
private static String getString(JsonObject json, String key, String def) {
138+
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : def;
139+
}
140+
141+
private static boolean getBool(JsonObject json, String key, boolean def) {
142+
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsBoolean() : def;
143+
}
144+
145+
private static int getInt(JsonObject json, String key, int def) {
146+
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsInt() : def;
147+
}
148+
149+
/**
150+
* Value object returned by the factory so callers can configure both
151+
* the driver capabilities and the Appium server URL.
152+
*/
153+
public static final class Result {
154+
private final DesiredCapabilities capabilities;
155+
private final String appiumServerUrl;
156+
157+
Result(DesiredCapabilities capabilities, String appiumServerUrl) {
158+
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
159+
this.appiumServerUrl = Objects.requireNonNull(appiumServerUrl, "appiumServerUrl");
160+
}
161+
162+
public DesiredCapabilities getCapabilities() {
163+
return capabilities;
164+
}
165+
166+
public String getAppiumServerUrl() {
167+
return appiumServerUrl;
168+
}
169+
}
170+
171+
}

android/src/org/testar/monkey/alayer/android/AndroidState.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/***************************************************************************************************
22
*
3-
* Copyright (c) 2020 - 2022 Universitat Politecnica de Valencia - www.upv.es
4-
* Copyright (c) 2020 - 2022 Open Universiteit - www.ou.nl
3+
* Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es
4+
* Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl
55
*
66
* Redistribution and use in source and binary forms, with or without
77
* modification, are permitted provided that the following conditions are met:
@@ -134,7 +134,7 @@ else if (w.element == null || w.tags.containsKey(t)) {
134134
ret = w.element.className;
135135
}
136136
else if (t.equals(Tags.Role)) {
137-
ret = AndroidRoles.AndroidWidget;
137+
ret = AndroidRoles.fromTypeId(w.element.className);
138138
}
139139
else if (t.equals(Tags.HitTester)) {
140140
ret = new org.testar.monkey.alayer.android.AndroidHitTester(w.element);

0 commit comments

Comments
 (0)