diff --git a/android/src/org/testar/monkey/alayer/android/AndroidAppiumFramework.java b/android/src/org/testar/monkey/alayer/android/AndroidAppiumFramework.java index ea17bad24..bf741c601 100644 --- a/android/src/org/testar/monkey/alayer/android/AndroidAppiumFramework.java +++ b/android/src/org/testar/monkey/alayer/android/AndroidAppiumFramework.java @@ -118,6 +118,10 @@ public AndroidAppiumFramework(DesiredCapabilities cap) { } catch (MalformedURLException e) { System.err.println("ERROR: Exception with Android Driver URL: http://0.0.0.0:4723/wd/hub"); e.printStackTrace(); + } catch (WebDriverException wde) { + markDriverUnresponsive(wde); + } catch (Throwable t) { + markDriverUnresponsive(t); } } @@ -159,7 +163,26 @@ static void resetDriverUnresponsive() { driverUnresponsive = false; } + private static boolean hasDriver(String operation) { + if (driver != null) { + return true; + } + + markDriverUnresponsive(new IllegalStateException("Android driver is null while executing " + operation)); + return false; + } + + private static IllegalStateException missingDriverException(String operation) { + IllegalStateException exception = new IllegalStateException("Android driver is null while executing " + operation); + markDriverUnresponsive(exception); + return exception; + } + public static List findElements(By by){ + if (!hasDriver("findElements")) { + return Collections.emptyList(); + } + return driver.findElements(by); } @@ -172,6 +195,10 @@ public static List findElements(By by){ * @return android web element */ public static WebElement resolveElementByIdOrXPath(String id, Widget w) { + if (driver == null) { + throw missingDriverException("resolveElementByIdOrXPath"); + } + if (id != null && !id.isEmpty()) { // Try by accessibility id only if non-null and non-empty List elements = driver.findElements(new AppiumBy.ByAccessibilityId(id)); @@ -194,11 +221,19 @@ public static WebElement resolveElementByIdOrXPath(String id, Widget w) { * @return android web element */ public static WebElement resolveElementByXPath(Widget w) { + if (driver == null) { + throw missingDriverException("resolveElementByXPath"); + } + String xpathString = w.get(AndroidTags.AndroidXpath); return driver.findElement(new By.ByXPath(xpathString)); } public static void scrollElementById(String id, Widget w, int scrollDistance) { + if (!hasDriver("scrollElementById")) { + return; + } + Duration NO_TIME = Duration.ofMillis(0); Duration STEP_DURATION = Duration.ofMillis(20); @@ -233,6 +268,10 @@ public static void scrollElementById(String id, Widget w, int scrollDistance) { } public static void longClickElementById(String id, Widget w) { + if (!hasDriver("longClickElementById")) { + return; + } + WebElement el; if (!id.equals("")) { el = driver.findElement(new AppiumBy.ByAccessibilityId(id)); @@ -248,6 +287,10 @@ public static void longClickElementById(String id, Widget w) { } public static void clickBackButton() { + if (!hasDriver("clickBackButton")) { + return; + } + driver.navigate().back(); } @@ -317,6 +360,10 @@ private static Sequence zoomSinglefinger(String fingerName, Point locus, int sta //System actions: public static void changeOrientation() { + if (!hasDriver("changeOrientation")) { + return; + } + ScreenOrientation orientation = driver.getOrientation(); if (orientation.value().equals("portrait")) { @@ -329,6 +376,10 @@ public static void changeOrientation() { } public static void generatePhoneCall() { + if (!hasDriver("generatePhoneCall")) { + return; + } + String phoneNumber = "1234567890"; driver.makeGsmCall(phoneNumber, GsmCallActions.CALL); @@ -342,6 +393,10 @@ public static void generatePhoneCall() { } public static void generateText() { + if (!hasDriver("generateText")) { + return; + } + String phoneNumber = "1234567890"; String textMessage = "Hallo Tester, Testar says hi!"; driver.sendSMS(phoneNumber, textMessage); @@ -354,6 +409,10 @@ public static void generateText() { } public static String getCurrentPackage() { + if (!hasDriver("getCurrentPackage")) { + return ""; + } + try { return driver.getCurrentPackage(); } catch (WebDriverException wde) { @@ -363,19 +422,35 @@ public static String getCurrentPackage() { } public static void pressKeyEvent(KeyEvent keyEvent){ + if (!hasDriver("pressKeyEvent")) { + return; + } + driver.pressKey(keyEvent); } // Utility Interactions public static void hideKeyboard(){ + if (!hasDriver("hideKeyboard")) { + return; + } + driver.hideKeyboard(); } public static void wakeUpKeyCode(){ + if (!hasDriver("wakeUpKeyCode")) { + return; + } + driver.pressKey(new KeyEvent(AndroidKey.WAKEUP)); } public static void activateAppByBundleId(String bundleId){ + if (!hasDriver("activateAppByBundleId")) { + return; + } + driver.activateApp(bundleId); } @@ -385,18 +460,34 @@ public static void activateAppByBundleId(String bundleId){ }*/ public static Set getWindowHandles(){ + if (!hasDriver("getWindowHandles")) { + return Collections.emptySet(); + } + return driver.getWindowHandles(); } public static String getTitleOfCurrentPage(){ + if (!hasDriver("getTitleOfCurrentPage")) { + return ""; + } + return driver.getTitle(); } public static void runAppInBackground(Duration duration){ + if (!hasDriver("runAppInBackground")) { + return; + } + driver.runAppInBackground(duration); } public static void pushFile(String remotePath, File file){ + if (!hasDriver("pushFile")) { + return; + } + try { driver.pushFile(remotePath, file); } catch (IOException e) { @@ -405,6 +496,10 @@ public static void pushFile(String remotePath, File file){ } public static String getActivity() { + if (!hasDriver("getActivity")) { + return ""; + } + try { return driver.currentActivity(); } catch (WebDriverException wde) { @@ -414,6 +509,10 @@ public static String getActivity() { } public static String getScreenshotSpyMode(String stateID) throws IOException { + if (!hasDriver("getScreenshotSpyMode")) { + throw new IOException("Exception: AndroidDriver getScreenshotSpyMode failed because driver is null"); + } + String scrshotOutputFolder = "output" + File.separator + "android_spy_screenshots"; String statePath = scrshotOutputFolder + File.separator + stateID + ".png"; File srcFile = driver.getScreenshotAs(OutputType.FILE); @@ -423,6 +522,10 @@ public static String getScreenshotSpyMode(String stateID) throws IOException { } public static String getScreenshotState(State state) throws IOException { + if (!hasDriver("getScreenshotState")) { + throw new IOException("Exception: AndroidDriver getScreenshotState failed because driver is null"); + } + try { byte[] byteImage = driver.getScreenshotAs(OutputType.BYTES); InputStream is = new ByteArrayInputStream(byteImage); @@ -435,6 +538,10 @@ public static String getScreenshotState(State state) throws IOException { } public static String getScreenshotAction(State state, Action action) throws IOException { + if (!hasDriver("getScreenshotAction")) { + throw new IOException("Exception: AndroidDriver getScreenshotAction failed because driver is null"); + } + byte[] byteImage; InputStream is; try { @@ -474,6 +581,10 @@ public static String getScreenshotAction(State state, Action action) throws IOEx } public static AWTCanvas getScreenshotBinary(State state) throws IOException { + if (!hasDriver("getScreenshotBinary")) { + throw new IOException("Exception: AndroidDriver getScreenshotBinary failed because driver is null"); + } + try { byte[] byteImage = driver.getScreenshotAs(OutputType.BYTES); InputStream is = new ByteArrayInputStream(byteImage); @@ -485,6 +596,10 @@ public static AWTCanvas getScreenshotBinary(State state) throws IOException { } public static void terminateApp(String bundleId){ + if (!hasDriver("terminateApp")) { + return; + } + driver.terminateApp(bundleId); } @@ -496,19 +611,41 @@ public static void terminateApp(String bundleId){ * * @return Document with DOM representation */ - public static Document getAndroidPageSource() { + public static AndroidPageSourceResult getAndroidPageSource() { + if (!hasDriver("getAndroidPageSource")) { + String feedback = "Exception trying to obtain driver.getPageSource(): Android driver is null"; + System.err.println("ERROR: " + feedback); + return new AndroidPageSourceResult(null, feedback); + } + try { String appiumState = driver.getPageSource(); - return loadXML(appiumState); + Document document = loadXML(appiumState); + return new AndroidPageSourceResult(document, ""); } catch (WebDriverException wde) { - System.err.println("ERROR: Exception trying to obtain driver.getPageSource()"); + String feedback = "Exception trying to obtain driver.getPageSource()"; + if (wde.getMessage() != null && !wde.getMessage().isEmpty()) { + feedback += ": " + wde.getMessage(); + } + markDriverUnresponsive(wde); + System.err.println("ERROR: " + feedback); + return new AndroidPageSourceResult(null, feedback); } catch (ParserConfigurationException | SAXException | IOException doce) { - System.err.println("ERROR: Exception parsing Android Driver Page Source to XML Document"); + String feedback = "Exception parsing Android Driver Page Source to XML Document"; + if (doce.getMessage() != null && !doce.getMessage().isEmpty()) { + feedback += ": " + doce.getMessage(); + } + System.err.println("ERROR: " + feedback); + return new AndroidPageSourceResult(null, feedback); } catch (Exception e) { - System.err.println("ERROR: Unknown Exception AppiumFramework getAndroidPageSource()"); + String feedback = "Unknown Exception AppiumFramework getAndroidPageSource()"; + if (e.getMessage() != null && !e.getMessage().isEmpty()) { + feedback += ": " + e.getMessage(); + } + System.err.println("ERROR: " + feedback); e.printStackTrace(); + return new AndroidPageSourceResult(null, feedback); } - return null; } private static Document loadXML(String xml) throws ParserConfigurationException, SAXException, IOException { @@ -519,6 +656,10 @@ private static Document loadXML(String xml) throws ParserConfigurationException, } public static void uninstallApp(String appName) { + if (!hasDriver("uninstallApp")) { + return; + } + System.out.println("Uninstalling app: " + appName); driver.removeApp(appName); @@ -530,6 +671,10 @@ public static void uninstallApp(String appName) { } public static LogEntries getAppiumLogs() { + if (!hasDriver("getAppiumLogs")) { + return new LogEntries(Collections.emptyList()); + } + return driver.manage().logs().get("driver"); } @@ -546,6 +691,10 @@ public static void clearLogcat() { * Execute an Android shell command on the device via Appium ("mobile: shell"). */ private static void mobileShell(String command, List args, Duration timeout) { + if (!hasDriver("mobileShell")) { + return; + } + Map m = new HashMap<>(); m.put("command", command); m.put("args", args); @@ -575,6 +724,10 @@ public static String dumpLogcatThreadtimeForPackage(String pkg) { * Execute an Android shell command on the device via Appium ("mobile: shell") and return stdout. */ private static String mobileShellStdout(String command, List args, Duration timeout) { + if (!hasDriver("mobileShellStdout")) { + return ""; + } + Map m = new HashMap<>(); m.put("command", command); m.put("args", args); @@ -597,7 +750,7 @@ private static String mobileShellStdout(String command, List args, Durat } public static String getAppPackageFromCapabilitiesOrCurrent() { - if (driver == null) { + if (!hasDriver("getAppPackageFromCapabilitiesOrCurrent")) { return ""; } @@ -627,12 +780,20 @@ public static String getAppPackageFromCapabilitiesOrCurrent() { @Override public void stop() throws SystemStopException { + if (driver == null) { + return; + } + driver.quit(); driver = null; } @Override public boolean isRunning() { + if (!hasDriver("isRunning")) { + return false; + } + //TODO: Check and select proper method to verify if running try { // Need to know appId to use this. @@ -652,6 +813,10 @@ public boolean isRunning() { @Override public String getStatus() { + if (!hasDriver("getStatus")) { + return "Android current package : "; + } + //TODO: Check and select proper method to print the status try { return "Android current package : " + driver.getCurrentPackage(); @@ -662,6 +827,10 @@ public String getStatus() { } public static ApplicationState getStatus(String appId) { + if (!hasDriver("getStatus(appId)")) { + return ApplicationState.NOT_RUNNING; + } + try { return driver.queryAppState(appId); } catch (WebDriverException wde) { diff --git a/android/src/org/testar/monkey/alayer/android/AndroidPageSourceResult.java b/android/src/org/testar/monkey/alayer/android/AndroidPageSourceResult.java new file mode 100644 index 000000000..012a681f5 --- /dev/null +++ b/android/src/org/testar/monkey/alayer/android/AndroidPageSourceResult.java @@ -0,0 +1,56 @@ +/*************************************************************************************************** + * + * Copyright (c) 2026 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2026 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 org.w3c.dom.Document; + +public final class AndroidPageSourceResult { + + private final Document document; + private final String feedback; + + public AndroidPageSourceResult(Document document, String feedback) { + this.document = document; + this.feedback = feedback; + } + + public Document getDocument() { + return document; + } + + public String getFeedback() { + return feedback; + } + + public boolean hasDocument() { + return document != null; + } +} diff --git a/android/src/org/testar/monkey/alayer/android/AndroidStateFetcher.java b/android/src/org/testar/monkey/alayer/android/AndroidStateFetcher.java index fecebb739..ee6a1700c 100644 --- a/android/src/org/testar/monkey/alayer/android/AndroidStateFetcher.java +++ b/android/src/org/testar/monkey/alayer/android/AndroidStateFetcher.java @@ -49,6 +49,7 @@ public class AndroidStateFetcher implements Callable { private Rect biggestRect = Rect.from(0, 0, 0, 0); private String androidActivityVar; + private String stateFeedback = ""; public AndroidStateFetcher(SUT system) { this.system = system; @@ -79,6 +80,9 @@ public AndroidState call() throws Exception { AndroidState root = createWidgetTree(rootElement); root.set(Tags.Role, Roles.Process); root.set(Tags.NotResponding, false); + if (!stateFeedback.isEmpty()) { + root.set(Tags.StateFeedback, stateFeedback); + } // After create the widget tree, set widgets Path for (Widget w : root) { @@ -99,8 +103,11 @@ private AndroidRootElement buildAndroidSkeleton(SUT system) { rootElement.pid = system.get(Tags.PID, (long)-1); + AndroidPageSourceResult pageSourceResult = AndroidAppiumFramework.getAndroidPageSource(); + stateFeedback = pageSourceResult.getFeedback(); + Document xmlAndroid; - if((xmlAndroid = AndroidAppiumFramework.getAndroidPageSource()) != null) { + if((xmlAndroid = pageSourceResult.getDocument()) != null) { Node stateNode = xmlAndroid.getDocumentElement(); if(stateNode.hasChildNodes()) { diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionClick.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionClick.java index 8249118d1..818ad6dcd 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionClick.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionClick.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es - * Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl + * Copyright (c) 2020 - 2026 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2020 - 2026 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: @@ -41,30 +41,30 @@ public class AndroidActionClick extends TaggableBase implements Action { private static final long serialVersionUID = 6663144395605910140L; - private String text; - private String accessibilityID; - private Widget widget; - private String widgetClass; - private String xpath; + private final String text; + private final String accessibilityId; + private final Widget widget; + private final String widgetClass; + private final String xpath; public AndroidActionClick(State state, Widget w) { this.set(Tags.Role, ActionRoles.LeftClickAt); this.mapOriginWidget(w); this.text = w.get(AndroidTags.AndroidText, ""); - this.accessibilityID = w.get(AndroidTags.AndroidAccessibilityId, ""); + this.accessibilityId = w.get(AndroidTags.AndroidAccessibilityId, ""); this.widget = w; - this.widgetClass = w.get(AndroidTags.AndroidClassName); - this.xpath = w.get(AndroidTags.AndroidXpath); + this.widgetClass = w.get(AndroidTags.AndroidClassName, ""); + this.xpath = w.get(AndroidTags.AndroidXpath, ""); this.set(Tags.Desc, toShortString()); } @Override public void run(SUT system, State state, double duration) throws ActionFailedException { try { - WebElement element = AndroidAppiumFramework.resolveElementByIdOrXPath(this.accessibilityID, this.widget); + WebElement element = AndroidAppiumFramework.resolveElementByIdOrXPath(this.accessibilityId, this.widget); element.click(); } catch(Exception e) { - System.out.println("Exception trying to click Element By Id : " + this.accessibilityID); + System.out.println("Exception trying to click Element By Id : " + this.accessibilityId); System.out.println(e.getMessage()); throw new ActionFailedException(toShortString()); } @@ -72,17 +72,23 @@ public void run(SUT system, State state, double duration) throws ActionFailedExc @Override public String toShortString() { - return "Execute Android click on Widget of type: '" + this.widgetClass + "', with text: '" + text + "', with Id: '" + accessibilityID + "', with xPath: " + xpath; + return "Execute Android click on Widget of type: '" + this.widgetClass + "', with text: '" + this.text + "', with Id: '" + this.accessibilityId + "', with xPath: " + this.xpath; } @Override public String toParametersString() { - return ""; + String widgetConcreteId = widget.get(Tags.ConcreteID, "NoWidgetConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, ActionRoles.LeftClickAt) + + ",widget=" + widgetConcreteId + + ",widgetClass=" + this.widgetClass + + ",text=" + this.text + + ",accessibilityId=" + this.accessibilityId + + ",xpath=" + this.xpath; } @Override public String toString(Role... discardParameters) { - return ""; + return toParametersString(); } public Widget getWidget(){ diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionLongClick.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionLongClick.java index 216cf7604..8a5d85875 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionLongClick.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionLongClick.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl - * Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2020 - 2026 Open Universiteit - www.ou.nl + * Copyright (c) 2020 - 2026 Universitat Politecnica de Valencia - www.upv.es * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -31,6 +31,7 @@ package org.testar.monkey.alayer.android.actions; import org.testar.monkey.alayer.*; +import org.testar.monkey.alayer.actions.ActionRoles; import org.testar.monkey.alayer.exceptions.ActionFailedException; import org.testar.monkey.alayer.android.AndroidAppiumFramework; import org.testar.monkey.alayer.android.enums.AndroidRoles; @@ -51,9 +52,9 @@ public AndroidActionLongClick(State state, Widget w) { this.mapOriginWidget(w); this.accessibilityId = w.get(AndroidTags.AndroidAccessibilityId, ""); this.widget = w; - this.widgetClass = w.get(AndroidTags.AndroidClassName); - this.xpath = w.get(AndroidTags.AndroidXpath); - this.text = w.get(AndroidTags.AndroidText); + this.widgetClass = w.get(AndroidTags.AndroidClassName, ""); + this.xpath = w.get(AndroidTags.AndroidXpath, ""); + this.text = w.get(AndroidTags.AndroidText, ""); this.set(Tags.Desc, toShortString()); } @@ -74,14 +75,20 @@ public String toShortString() { return "Execute Android Longclick on Widget of type: '" + this.widgetClass + "', with text: '" + this.text + "', with Id: '" + this.accessibilityId + "', with xPath: " + this.xpath; } - @Override - public String toParametersString() { - return ""; - } + @Override + public String toParametersString() { + String widgetConcreteId = widget.get(Tags.ConcreteID, "NoWidgetConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, ActionRoles.LeftClickAt) + + ",widget=" + widgetConcreteId + + ",widgetClass=" + this.widgetClass + + ",text=" + this.text + + ",accessibilityId=" + this.accessibilityId + + ",xpath=" + this.xpath; + } @Override public String toString(Role... discardParameters) { - return ""; + return toParametersString(); } public Widget getWidget(){ diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionPinch.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionPinch.java deleted file mode 100644 index 8fa859826..000000000 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionPinch.java +++ /dev/null @@ -1,102 +0,0 @@ -/*************************************************************************************************** - * - * Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl - * Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es - * - * 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.actions; - -import org.testar.monkey.alayer.*; -import org.testar.monkey.alayer.exceptions.ActionFailedException; -import org.testar.monkey.alayer.android.enums.AndroidRoles; -import org.testar.monkey.alayer.android.enums.AndroidTags; - -/** - * TODO: currently not working. - */ -public class AndroidActionPinch extends TaggableBase implements Action { - - private final String accessibilityId; - private final Widget widget; - private final boolean zoomIn; - - public AndroidActionPinch(State state, Widget w, boolean zoomIn) { - this.set(Tags.Role, AndroidRoles.AndroidWidget); - this.mapOriginWidget(w); - this.accessibilityId = w.get(AndroidTags.AndroidAccessibilityId, ""); - this.widget = w; - this.set(Tags.Desc, toShortString()); - this.zoomIn = zoomIn; - - } - - @Override - public void run(SUT system, State state, double duration) throws ActionFailedException { - //TODO Implement pinch (zoomin and zoomout) -// int xValue = (int)(widget.get(AndroidTags.AndroidBounds).width()/2.0); -// int yValue = (int)(widget.get(AndroidTags.AndroidBounds).height()/2.0); -// Point center = new Point(xValue, yValue); -// int distance = 300; -// if (zoomIn) { -// // Pinch for zooming in -// try { -// AndroidAppiumFramework.zoomIn(center, distance); -// } catch(Exception e) { -// System.out.println("Exception trying to zoom in Element By Id : " + this.accessibilityId); -// System.out.println(e.getMessage()); -// throw new ActionFailedException(toShortString()); -// } -// -// } else { -// // Pinch for zooming out -// try { -// AndroidAppiumFramework.zoomOut(center, distance); -// } catch(Exception e) { -// System.out.println("Exception trying to zoom out Element By Id : " + this.accessibilityId); -// System.out.println(e.getMessage()); -// throw new ActionFailedException(toShortString()); -// } -// } - } - - - - @Override - public String toShortString() { - return "Execute Android pinch in Widget: " + widget.get(AndroidTags.AndroidClassName) + " with Id: " + this.accessibilityId; - } - - @Override - public String toParametersString() { - return ""; - } - - @Override - public String toString(Role... discardParameters) { - return ""; - } -} diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionScroll.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionScroll.java index 1365507dd..b26ba1472 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionScroll.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionScroll.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl - * Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2020 - 2026 Open Universiteit - www.ou.nl + * Copyright (c) 2020 - 2026 Universitat Politecnica de Valencia - www.upv.es * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -40,22 +40,28 @@ public class AndroidActionScroll extends TaggableBase implements Action { private static final long serialVersionUID = 6205133391190145934L; - private final int scrollDistance = 500; + private static final int SCROLL_DISTANCE = 500; private final String accessibilityId; private final Widget widget; + private final String widgetClass; + private final String text; + private final String xpath; public AndroidActionScroll(State state, Widget w) { this.set(Tags.Role, AndroidRoles.AndroidWidget); this.mapOriginWidget(w); this.accessibilityId = w.get(AndroidTags.AndroidAccessibilityId, ""); this.widget = w; + this.widgetClass = w.get(AndroidTags.AndroidClassName, ""); + this.text = w.get(AndroidTags.AndroidText, ""); + this.xpath = w.get(AndroidTags.AndroidXpath, ""); this.set(Tags.Desc, toShortString()); } @Override public void run(SUT system, State state, double duration) throws ActionFailedException { try { - AndroidAppiumFramework.scrollElementById(this.accessibilityId, this.widget, this.scrollDistance); + AndroidAppiumFramework.scrollElementById(this.accessibilityId, this.widget, SCROLL_DISTANCE); } catch(Exception e) { System.out.println("Exception trying to scroll Element By Id : " + this.accessibilityId); System.out.println(e.getMessage()); @@ -70,15 +76,22 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String widgetConcreteId = this.widget.get(Tags.ConcreteID, "NoWidgetConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, AndroidRoles.AndroidWidget) + + ",widget=" + widgetConcreteId + + ",widgetClass=" + this.widgetClass + + ",text=" + this.text + + ",accessibilityId=" + this.accessibilityId + + ",xpath=" + this.xpath + + ",scrollDistance=" + SCROLL_DISTANCE; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } public Widget getWidget(){ - return widget; + return this.widget; } } diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionType.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionType.java index ae90cd9d9..2b34ff372 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidActionType.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidActionType.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2020 - 2025 Universitat Politecnica de Valencia - www.upv.es - * Copyright (c) 2020 - 2025 Open Universiteit - www.ou.nl + * Copyright (c) 2020 - 2026 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2020 - 2026 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: @@ -46,11 +46,12 @@ public class AndroidActionType extends TaggableBase implements Action { private final String accessibilityId; private final Widget widget; private final String widgetClass; + private final String text; private final String xpath; private static final Pen TypePen = Pen.newPen().setColor(Color.Blue) .setFillPattern(FillPattern.None).setStrokeWidth(3).build(); // use default font size - private final int DISPLAY_TEXT_MAX_LENGTH = 16; + private static final int DISPLAY_TEXT_MAX_LENGTH = 16; public AndroidActionType(State state, Widget w, String typeText) { this.set(Tags.Role, ActionRoles.ClickTypeInto); @@ -59,7 +60,8 @@ public AndroidActionType(State state, Widget w, String typeText) { this.accessibilityId = w.get(AndroidTags.AndroidAccessibilityId, ""); this.widget = w; this.widgetClass = w.get(AndroidTags.AndroidClassName, ""); - this.xpath = w.get(AndroidTags.AndroidXpath); + this.text = w.get(AndroidTags.AndroidText, ""); + this.xpath = w.get(AndroidTags.AndroidXpath, ""); double relX = w.get(Tags.Shape).x() + w.get(Tags.Shape).width()/2; double relY = w.get(Tags.Shape).y() + w.get(Tags.Shape).height()/2; Position position = new AbsolutePosition(relX, relY); @@ -89,16 +91,24 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String widgetConcreteId = this.widget.get(Tags.ConcreteID, "NoWidgetConcreteIdAvailable"); + String inputText = this.get(Tags.InputText, ""); + return "role=" + this.get(Tags.Role, ActionRoles.ClickTypeInto) + + ",widget=" + widgetConcreteId + + ",widgetClass=" + this.widgetClass + + ",text=" + this.text + + ",accessibilityId=" + this.accessibilityId + + ",xpath=" + this.xpath + + ",inputText=" + inputText; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } public Widget getWidget(){ - return widget; + return this.widget; } } diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java index 4652d57d1..dcaef5c98 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java @@ -38,10 +38,12 @@ public class AndroidBackAction extends TaggableBase implements Action { private static final long serialVersionUID = 6808185819403073531L; + private final State originState; public AndroidBackAction(State state) { this.set(Tags.Role, AndroidRoles.AndroidWidget); this.mapOriginWidget(state); + this.originState = state; this.set(Tags.Desc, toShortString()); } @@ -63,11 +65,14 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String stateConcreteId = this.originState.get(Tags.ConcreteID, "NoStateConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, AndroidRoles.AndroidWidget) + + ",state=" + stateConcreteId + + ",action=back"; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } } diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java index 70724bd76..2d82b7ced 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java @@ -38,10 +38,12 @@ public class AndroidSystemActionCall extends TaggableBase implements Action { private static final long serialVersionUID = -6556855399091167635L; + private final State originState; public AndroidSystemActionCall(State state) { this.set(Tags.Role, AndroidRoles.AndroidWidget); this.mapOriginWidget(state); + this.originState = state; this.set(Tags.Desc, toShortString()); } @@ -63,12 +65,15 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String stateConcreteId = this.originState.get(Tags.ConcreteID, "NoStateConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, AndroidRoles.AndroidWidget) + + ",state=" + stateConcreteId + + ",action=systemCall"; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } } diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java index 1b8a2fb4f..2c55325ec 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java @@ -38,10 +38,12 @@ public class AndroidSystemActionOrientation extends TaggableBase implements Action { private static final long serialVersionUID = 138171508205790215L; + private final State originState; public AndroidSystemActionOrientation(State state) { this.set(Tags.Role, AndroidRoles.AndroidWidget); this.mapOriginWidget(state); + this.originState = state; this.set(Tags.Desc, toShortString()); } @@ -63,12 +65,15 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String stateConcreteId = this.originState.get(Tags.ConcreteID, "NoStateConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, AndroidRoles.AndroidWidget) + + ",state=" + stateConcreteId + + ",action=systemOrientation"; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } } diff --git a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java index 48c622400..97f05325e 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java @@ -38,10 +38,12 @@ public class AndroidSystemActionText extends TaggableBase implements Action { private static final long serialVersionUID = -3642791359981165420L; + private final State originState; public AndroidSystemActionText(State state) { this.set(Tags.Role, AndroidRoles.AndroidWidget); this.mapOriginWidget(state); + this.originState = state; this.set(Tags.Desc, toShortString()); } @@ -63,12 +65,15 @@ public String toShortString() { @Override public String toParametersString() { - return ""; + String stateConcreteId = this.originState.get(Tags.ConcreteID, "NoStateConcreteIdAvailable"); + return "role=" + this.get(Tags.Role, AndroidRoles.AndroidWidget) + + ",state=" + stateConcreteId + + ",action=systemText"; } @Override public String toString(Role... discardParameters) { - return ""; + return this.toParametersString(); } } diff --git a/android/test/org/testar/monkey/alayer/android/AndroidPageSourceResultTest.java b/android/test/org/testar/monkey/alayer/android/AndroidPageSourceResultTest.java new file mode 100644 index 000000000..8fdda844c --- /dev/null +++ b/android/test/org/testar/monkey/alayer/android/AndroidPageSourceResultTest.java @@ -0,0 +1,28 @@ +package org.testar.monkey.alayer.android; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.w3c.dom.Document; + +public class AndroidPageSourceResultTest { + + @Test + public void resultWithDocument_reportsDocumentAndFeedback() { + Document document = Mockito.mock(Document.class); + AndroidPageSourceResult result = new AndroidPageSourceResult(document, ""); + + Assert.assertTrue(result.hasDocument()); + Assert.assertSame(document, result.getDocument()); + Assert.assertEquals("", result.getFeedback()); + } + + @Test + public void resultWithoutDocument_reportsMissingDocumentAndFeedback() { + AndroidPageSourceResult result = new AndroidPageSourceResult(null, "page source failed"); + + Assert.assertFalse(result.hasDocument()); + Assert.assertNull(result.getDocument()); + Assert.assertEquals("page source failed", result.getFeedback()); + } +} diff --git a/android/test/org/testar/monkey/alayer/android/TestAndroidDriverNullGuard.java b/android/test/org/testar/monkey/alayer/android/TestAndroidDriverNullGuard.java new file mode 100644 index 000000000..9de6d03a6 --- /dev/null +++ b/android/test/org/testar/monkey/alayer/android/TestAndroidDriverNullGuard.java @@ -0,0 +1,116 @@ +package org.testar.monkey.alayer.android; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import io.appium.java_client.appmanagement.ApplicationState; +import org.testar.monkey.alayer.State; +import org.testar.monkey.alayer.SUT; +import org.testar.monkey.alayer.Tags; +import org.testar.monkey.alayer.Widget; +import org.testar.monkey.alayer.android.enums.AndroidTags; + +import java.io.IOException; +import java.lang.reflect.Field; + +public class TestAndroidDriverNullGuard { + + @After + public void cleanup() throws Exception { + setStaticDriver(null); + AndroidAppiumFramework.resetDriverUnresponsive(); + } + + @Test + public void getActivity_WhenDriverIsNull_ReturnsEmptyAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + Assert.assertEquals("", AndroidAppiumFramework.getActivity()); + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + + State state = buildStateWithUnresponsiveFlag(); + Assert.assertTrue(state.get(Tags.NotResponding, false)); + } + + @Test + public void getAndroidPageSource_WhenDriverIsNull_ReturnsFeedbackAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + AndroidPageSourceResult result = AndroidAppiumFramework.getAndroidPageSource(); + + Assert.assertFalse(result.hasDocument()); + Assert.assertNull(result.getDocument()); + Assert.assertTrue(result.getFeedback().contains("Android driver is null")); + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + + State state = buildStateWithUnresponsiveFlag(); + Assert.assertTrue(state.get(Tags.NotResponding, false)); + } + + @Test + public void getCurrentPackage_WhenDriverIsNull_ReturnsEmptyAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + Assert.assertEquals("", AndroidAppiumFramework.getCurrentPackage()); + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + } + + @Test + public void getScreenshotState_WhenDriverIsNull_ThrowsIoExceptionAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + State state = Mockito.mock(State.class); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + try { + AndroidAppiumFramework.getScreenshotState(state); + Assert.fail("Expected IOException"); + } catch (IOException expected) { + Assert.assertTrue(expected.getMessage().contains("driver is null")); + } + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + } + + @Test + public void getStatus_WhenDriverIsNull_ReturnsNotRunningAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + Assert.assertEquals(ApplicationState.NOT_RUNNING, AndroidAppiumFramework.getStatus("com.testar.app")); + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + } + + @Test + public void resolveElementByIdOrXPath_WhenDriverIsNull_ThrowsControlledExceptionAndMarksUnresponsive() throws Exception { + setStaticDriver(null); + + Widget widget = Mockito.mock(Widget.class); + Mockito.when(widget.get(AndroidTags.AndroidXpath)).thenReturn("//android.widget.TextView[1]"); + + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + try { + AndroidAppiumFramework.resolveElementByIdOrXPath("some-id", widget); + Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException expected) { + Assert.assertTrue(expected.getMessage().contains("Android driver is null")); + } + Assert.assertTrue(AndroidAppiumFramework.isDriverUnresponsive()); + } + + private State buildStateWithUnresponsiveFlag() throws Exception { + SUT system = Mockito.mock(SUT.class); + Mockito.when(system.isRunning()).thenReturn(true); + AndroidStateBuilder builder = new AndroidStateBuilder(1.0); + return builder.apply(system); + } + + private void setStaticDriver(Object testDriver) throws Exception { + Field driver = AndroidAppiumFramework.class.getDeclaredField("driver"); + driver.setAccessible(true); + driver.set(null, testDriver); + } +} diff --git a/android/test/org/testar/monkey/alayer/android/TestAndroidDriverUnresponsive.java b/android/test/org/testar/monkey/alayer/android/TestAndroidDriverUnresponsive.java index 58fc7ed50..229716fff 100644 --- a/android/test/org/testar/monkey/alayer/android/TestAndroidDriverUnresponsive.java +++ b/android/test/org/testar/monkey/alayer/android/TestAndroidDriverUnresponsive.java @@ -94,6 +94,20 @@ public void getScreenshotState_DriverNotResponding() throws Exception { Assert.assertTrue(notResponding.get(Tags.NotResponding, false)); } + @Test + public void getAndroidPageSource_WhenDriverThrows_ReturnsFeedbackResult() throws Exception { + AndroidDriver driver = mock(AndroidDriver.class); + when(driver.getPageSource()).thenThrow(new WebDriverException("page source timeout")); + setStaticDriver(driver); + + AndroidPageSourceResult result = AndroidAppiumFramework.getAndroidPageSource(); + + Assert.assertFalse(result.hasDocument()); + Assert.assertNull(result.getDocument()); + Assert.assertTrue(result.getFeedback().contains("Exception trying to obtain driver.getPageSource()")); + Assert.assertTrue(result.getFeedback().contains("page source timeout")); + } + private State buildStateWithUnresponsiveFlag() throws Exception { SUT system = Mockito.mock(SUT.class); when(system.isRunning()).thenReturn(true); diff --git a/android/test/org/testar/monkey/alayer/android/actions/BuildAndroidActions.java b/android/test/org/testar/monkey/alayer/android/actions/BuildAndroidActions.java index 0a9cac3ce..8ba1629ce 100644 --- a/android/test/org/testar/monkey/alayer/android/actions/BuildAndroidActions.java +++ b/android/test/org/testar/monkey/alayer/android/actions/BuildAndroidActions.java @@ -50,14 +50,6 @@ public void buildAndroidActionLongClick() { Assert.isTrue(androidLongClick.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); } - @Test - public void buildAndroidActionPinch() { - Action androidPinch = new AndroidActionPinch(state, widget, false); - // Verify Action <-> Widget mapping - Assert.notNull(androidPinch.get(Tags.OriginWidget)); - Assert.isTrue(androidPinch.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); - } - @Test public void buildAndroidActionScroll() { Action androidScroll = new AndroidActionScroll(state, widget); diff --git a/android/test/org/testar/monkey/alayer/android/actions/TestAndroidActionIdentifiers.java b/android/test/org/testar/monkey/alayer/android/actions/TestAndroidActionIdentifiers.java new file mode 100644 index 000000000..36fa9b45b --- /dev/null +++ b/android/test/org/testar/monkey/alayer/android/actions/TestAndroidActionIdentifiers.java @@ -0,0 +1,174 @@ +package org.testar.monkey.alayer.android.actions; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.testar.CodingManager; +import org.testar.monkey.alayer.Action; +import org.testar.monkey.alayer.Rect; +import org.testar.monkey.alayer.Role; +import org.testar.monkey.alayer.Tags; +import org.testar.monkey.alayer.android.enums.AndroidTags; +import org.testar.stub.StateStub; +import org.testar.stub.WidgetStub; + +import java.util.LinkedHashSet; +import java.util.Set; + +public class TestAndroidActionIdentifiers { + + private StateStub state; + private WidgetStub widgetOne; + private WidgetStub widgetTwo; + + @Before + public void setUp() { + state = new StateStub(); + state.set(AndroidTags.AndroidXpath, "[0]"); + + widgetOne = buildWidget("[0,0,1]", "TextValueOne", "AccessibilityIdOne", "ClassNameOne", 0, 0, 100, 100); + widgetTwo = buildWidget("[0,0,2]", "TextValueTwo", "AccessibilityIdTwo", "ClassNameTwo", 100, 0, 200, 100); + + state.addChild(widgetOne); + widgetOne.setParent(state); + + state.addChild(widgetTwo); + widgetTwo.setParent(state); + + CodingManager.buildIDs(state); + } + + @Test + public void clickAction_toStringIsNotEmpty() { + Action action = new AndroidActionClick(state, widgetOne); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void longClickAction_toStringIsNotEmpty() { + Action action = new AndroidActionLongClick(state, widgetOne); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void typeAction_toStringIsNotEmpty() { + Action action = new AndroidActionType(state, widgetOne, "InputText"); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void scrollAction_toStringIsNotEmpty() { + Action action = new AndroidActionScroll(state, widgetOne); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void backAction_toStringIsNotEmpty() { + Action action = new AndroidBackAction(state); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void systemCallAction_toStringIsNotEmpty() { + Action action = new AndroidSystemActionCall(state); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void systemOrientationAction_toStringIsNotEmpty() { + Action action = new AndroidSystemActionOrientation(state); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void systemTextAction_toStringIsNotEmpty() { + Action action = new AndroidSystemActionText(state); + + Assert.assertFalse(action.toString(new Role[0]).isEmpty()); + } + + @Test + public void clickActions_haveDifferentConcreteIdsForDifferentWidgets() { + Action actionOne = new AndroidActionClick(state, widgetOne); + Action actionTwo = new AndroidActionClick(state, widgetTwo); + + buildActionIds(actionOne, actionTwo); + + Assert.assertNotEquals( + actionOne.get(Tags.ConcreteID, ""), + actionTwo.get(Tags.ConcreteID, "") + ); + } + + @Test + public void longClickActions_haveDifferentConcreteIdsForDifferentWidgets() { + Action actionOne = new AndroidActionLongClick(state, widgetOne); + Action actionTwo = new AndroidActionLongClick(state, widgetTwo); + + buildActionIds(actionOne, actionTwo); + + Assert.assertNotEquals( + actionOne.get(Tags.ConcreteID, ""), + actionTwo.get(Tags.ConcreteID, "") + ); + } + + @Test + public void typeActions_haveDifferentConcreteIdsForDifferentWidgets() { + Action actionOne = new AndroidActionType(state, widgetOne, "InputOne"); + Action actionTwo = new AndroidActionType(state, widgetTwo, "InputTwo"); + + buildActionIds(actionOne, actionTwo); + + Assert.assertNotEquals( + actionOne.get(Tags.ConcreteID, ""), + actionTwo.get(Tags.ConcreteID, "") + ); + } + + @Test + public void scrollActions_haveDifferentConcreteIdsForDifferentWidgets() { + Action actionOne = new AndroidActionScroll(state, widgetOne); + Action actionTwo = new AndroidActionScroll(state, widgetTwo); + + buildActionIds(actionOne, actionTwo); + + Assert.assertNotEquals( + actionOne.get(Tags.ConcreteID, ""), + actionTwo.get(Tags.ConcreteID, "") + ); + } + + private void buildActionIds(Action actionOne, Action actionTwo) { + Set actions = new LinkedHashSet<>(); + actions.add(actionOne); + actions.add(actionTwo); + CodingManager.buildIDs(state, actions); + } + + private WidgetStub buildWidget( + String xpath, + String text, + String accessibilityId, + String className, + double x1, + double y1, + double x2, + double y2 + ) { + WidgetStub widget = new WidgetStub(); + widget.set(Tags.Shape, Rect.fromCoordinates(x1, y1, x2, y2)); + widget.set(AndroidTags.AndroidText, text); + widget.set(AndroidTags.AndroidAccessibilityId, accessibilityId); + widget.set(AndroidTags.AndroidClassName, className); + widget.set(AndroidTags.AndroidXpath, xpath); + return widget; + } +} diff --git a/core/src/org/testar/monkey/alayer/Tags.java b/core/src/org/testar/monkey/alayer/Tags.java index 67e36d3ec..6f2104909 100644 --- a/core/src/org/testar/monkey/alayer/Tags.java +++ b/core/src/org/testar/monkey/alayer/Tags.java @@ -204,6 +204,11 @@ private Tags() {} */ public static final Tag StateRenderTime = from("StateRenderTime", Double.class); + /** + * Provides feedback to indicate state fetching or other state information. + */ + public static final Tag StateFeedback = from("StateFeedback", String.class); + /** * This method will return all the static tags that are available for use. * @return diff --git a/core/src/org/testar/serialisation/LogSerialiser.java b/core/src/org/testar/serialisation/LogSerialiser.java index 3e7af250e..6d1972369 100644 --- a/core/src/org/testar/serialisation/LogSerialiser.java +++ b/core/src/org/testar/serialisation/LogSerialiser.java @@ -1,6 +1,7 @@ /*************************************************************************************************** * -* Copyright (c) 2016, 2017 Universitat Politecnica de Valencia - www.upv.es +* Copyright (c) 2016 - 2026 Universitat Politecnica de Valencia - www.upv.es +* Copyright (c) 2019 - 2026 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: @@ -30,6 +31,9 @@ package org.testar.serialisation; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import java.io.PrintStream; import java.util.LinkedList; @@ -37,12 +41,11 @@ /** * Logs serialiser. - * - * @author Urko Rueda Molina (alias: urueda) - * */ public class LogSerialiser extends Thread { + protected static final Logger logger = LogManager.getLogger(); + private static PrintStream log; private static int logLevel; private static int logTimes; @@ -100,12 +103,17 @@ public void run(){ logthis(logR.logS, logR.logL); } } - log.flush(); - log.close(); - synchronized(log){ - //System.out.println("<" + singletonLogSerialiser.getName() + "> LogSerialiser finished"); + PrintStream currentLog = log; + if (currentLog != null){ + currentLog.flush(); + currentLog.close(); + synchronized(currentLog){ + singletonLogSerialiser = null; + currentLog.notifyAll(); + } + } else { + logger.error("LogSerialiser finished without an active PrintStream."); singletonLogSerialiser = null; - log.notifyAll(); } } @@ -133,7 +141,9 @@ private static void logthis(String string, LogLevel level){ } public static void flush(){ - log.flush(); + if (log != null){ + log.flush(); + } } public static PrintStream getLogStream(){ @@ -143,11 +153,17 @@ public static PrintStream getLogStream(){ public static void exit(){ if (singletonLogSerialiser != null){ LogSerialiser.finish(); + PrintStream currentLog = log; + if (currentLog == null){ + logger.error("LogSerialiser.exit() called while log stream is null."); + singletonLogSerialiser = null; + return; + } try { - synchronized(log){ + synchronized(currentLog){ while (singletonLogSerialiser != null){ try { - log.wait(10); + currentLog.wait(10); } catch (InterruptedException e) { System.out.println("LogSerialiser exit interrupted"); } diff --git a/core/src/org/testar/serialisation/ScreenshotSerialiser.java b/core/src/org/testar/serialisation/ScreenshotSerialiser.java index fd04e20b1..4ae8e205e 100644 --- a/core/src/org/testar/serialisation/ScreenshotSerialiser.java +++ b/core/src/org/testar/serialisation/ScreenshotSerialiser.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2015, 2016, 2017, 2018, 2019 Universitat Politecnica de Valencia - www.upv.es - * Copyright (c) 2018, 2019 Open Universiteit - www.ou.nl + * Copyright (c) 2015 - 2026 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2019 - 2026 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: @@ -31,6 +31,9 @@ package org.testar.serialisation; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -44,10 +47,11 @@ /** * SUT screenshots serialiser - * */ public class ScreenshotSerialiser extends Thread { + protected static final Logger logger = LogManager.getLogger(); + public static final String SCRSHOTS = "scrshots"; private static String testSequenceFolder = null; private static String scrshotOutputFolder = null; @@ -128,9 +132,15 @@ public void run(){ } } } - synchronized(testSequenceFolder){ + String currentTestSequenceFolder = testSequenceFolder; + if (currentTestSequenceFolder != null){ + synchronized(currentTestSequenceFolder){ + singletonScreenshotSerialiser = null; + currentTestSequenceFolder.notifyAll(); + } + } else { + logger.error("ScreenshotSerialiser finished without a test sequence folder."); singletonScreenshotSerialiser = null; - testSequenceFolder.notifyAll(); } } @@ -159,11 +169,17 @@ private static void savethis(String scrshotPath, AWTCanvas scrshot){ public static void exit(){ if (singletonScreenshotSerialiser != null){ ScreenshotSerialiser.finish(); + String currentTestSequenceFolder = testSequenceFolder; + if (currentTestSequenceFolder == null){ + logger.error("ScreenshotSerialiser.exit() called while testSequenceFolder is null."); + singletonScreenshotSerialiser = null; + return; + } try { - synchronized(testSequenceFolder){ + synchronized(currentTestSequenceFolder){ while (singletonScreenshotSerialiser != null){ try { - testSequenceFolder.wait(10); + currentTestSequenceFolder.wait(10); } catch (InterruptedException e) { System.out.println("ScreenshotSerialiser exit interrupted"); } diff --git a/core/src/org/testar/serialisation/TestSerialiser.java b/core/src/org/testar/serialisation/TestSerialiser.java index 6c7e1a127..35332085a 100644 --- a/core/src/org/testar/serialisation/TestSerialiser.java +++ b/core/src/org/testar/serialisation/TestSerialiser.java @@ -1,7 +1,7 @@ /*************************************************************************************************** * - * Copyright (c) 2016, 2017, 2019 Universitat Politecnica de Valencia - www.upv.es - * Copyright (c) 2019 Open Universiteit - www.ou.nl + * Copyright (c) 2016 - 2026 Universitat Politecnica de Valencia - www.upv.es + * Copyright (c) 2019 - 2026 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: @@ -31,6 +31,9 @@ package org.testar.serialisation; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import java.io.IOException; import java.io.ObjectOutputStream; import java.util.LinkedList; @@ -39,10 +42,12 @@ import org.testar.monkey.alayer.TaggableBase; /** - * Tests serialiser.. + * Tests serialiser. */ public class TestSerialiser extends Thread { + protected static final Logger logger = LogManager.getLogger(); + private static ObjectOutputStream test; private static int fragmentTimes; private static final int FLUSH_INTERVAL = 16; @@ -98,22 +103,28 @@ public void run(){ writethis(fragment); } } - try { - test.flush(); - test.close(); - } catch (IOException e) { - LogSerialiser.log("I/O exception serialising test file!\n", LogSerialiser.LogLevel.Critical); - } finally{ + ObjectOutputStream currentTest = test; + if (currentTest != null){ try { - test.close(); + currentTest.flush(); + currentTest.close(); } catch (IOException e) { - LogSerialiser.log("I/O exception closing serialisation of test file!\n", LogSerialiser.LogLevel.Critical); + LogSerialiser.log("I/O exception serialising test file!\n", LogSerialiser.LogLevel.Critical); + } finally{ + try { + currentTest.close(); + } catch (IOException e) { + LogSerialiser.log("I/O exception closing serialisation of test file!\n", LogSerialiser.LogLevel.Critical); + } } - } - synchronized(test){ - //System.out.println("TestSerialiser finished"); + synchronized(currentTest){ + //System.out.println("TestSerialiser finished"); + singletonTestSerialiser = null; + currentTest.notifyAll(); + } + } else { + logger.error("TestSerialiser finished without an active ObjectOutputStream."); singletonTestSerialiser = null; - test.notifyAll(); } } @@ -127,6 +138,10 @@ public static void write(TaggableBase fragment){ private static void writethis(TaggableBase fragment){ Assert.notNull(fragment); + if (test == null){ + logger.error("TestSerialiser skipped writing a fragment because the ObjectOutputStream is null."); + return; + } try { test.writeObject(fragment); } catch (IOException e) { @@ -147,11 +162,17 @@ private static void writethis(TaggableBase fragment){ public static void exit(){ if (singletonTestSerialiser != null){ TestSerialiser.finish(); + ObjectOutputStream currentTest = test; + if (currentTest == null){ + logger.error("TestSerialiser.exit() called while test stream is null."); + singletonTestSerialiser = null; + return; + } try { - synchronized(test){ + synchronized(currentTest){ while (singletonTestSerialiser != null){ try { - test.wait(); + currentTest.wait(); } catch (InterruptedException e) { System.out.println("TestSerialiser exit interrupted"); } diff --git a/core/test/org/testar/serialisation/LogSerialiserNullGuardTest.java b/core/test/org/testar/serialisation/LogSerialiserNullGuardTest.java new file mode 100644 index 000000000..ad03d05e8 --- /dev/null +++ b/core/test/org/testar/serialisation/LogSerialiserNullGuardTest.java @@ -0,0 +1,55 @@ +package org.testar.serialisation; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.LinkedList; + +public class LogSerialiserNullGuardTest { + + @After + public void cleanup() throws Exception { + setStaticField("log", null); + setStaticField("singletonLogSerialiser", null); + setStaticField("alive", false); + setStaticField("logSavingQueue", new LinkedList<>()); + } + + @Test + public void flush_WhenLogIsNull_DoesNotThrow() throws Exception { + setStaticField("log", null); + + LogSerialiser.flush(); + } + + @Test + public void exit_WhenLogIsNull_DoesNotThrowAndClearsSingleton() throws Exception { + setStaticField("log", null); + setStaticField("singletonLogSerialiser", newLogSerialiserInstance()); + + LogSerialiser.exit(); + + Assert.assertNull(getStaticField("singletonLogSerialiser")); + } + + private LogSerialiser newLogSerialiserInstance() throws Exception { + Constructor constructor = LogSerialiser.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private Object getStaticField(String fieldName) throws Exception { + Field field = LogSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(null); + } + + private void setStaticField(String fieldName, Object value) throws Exception { + Field field = LogSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/core/test/org/testar/serialisation/ScreenshotSerialiserNullGuardTest.java b/core/test/org/testar/serialisation/ScreenshotSerialiserNullGuardTest.java new file mode 100644 index 000000000..f296b8d48 --- /dev/null +++ b/core/test/org/testar/serialisation/ScreenshotSerialiserNullGuardTest.java @@ -0,0 +1,49 @@ +package org.testar.serialisation; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.LinkedList; + +public class ScreenshotSerialiserNullGuardTest { + + @After + public void cleanup() throws Exception { + setStaticField("singletonScreenshotSerialiser", null); + setStaticField("testSequenceFolder", null); + setStaticField("scrshotOutputFolder", null); + setStaticField("alive", false); + setStaticField("scrshotSavingQueue", new LinkedList<>()); + } + + @Test + public void exit_WhenTestSequenceFolderIsNull_DoesNotThrowAndClearsSingleton() throws Exception { + setStaticField("testSequenceFolder", null); + setStaticField("singletonScreenshotSerialiser", newScreenshotSerialiserInstance()); + + ScreenshotSerialiser.exit(); + + Assert.assertNull(getStaticField("singletonScreenshotSerialiser")); + } + + private ScreenshotSerialiser newScreenshotSerialiserInstance() throws Exception { + Constructor constructor = ScreenshotSerialiser.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private Object getStaticField(String fieldName) throws Exception { + Field field = ScreenshotSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(null); + } + + private void setStaticField(String fieldName, Object value) throws Exception { + Field field = ScreenshotSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/core/test/org/testar/serialisation/TestSerialiserNullGuardTest.java b/core/test/org/testar/serialisation/TestSerialiserNullGuardTest.java new file mode 100644 index 000000000..20cd1fd06 --- /dev/null +++ b/core/test/org/testar/serialisation/TestSerialiserNullGuardTest.java @@ -0,0 +1,59 @@ +package org.testar.serialisation; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.testar.monkey.alayer.TaggableBase; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedList; + +public class TestSerialiserNullGuardTest { + + @After + public void cleanup() throws Exception { + setStaticField("test", null); + setStaticField("singletonTestSerialiser", null); + setStaticField("alive", false); + setStaticField("testSavingQueue", new LinkedList<>()); + } + + @Test + public void exit_WhenTestStreamIsNull_DoesNotThrowAndClearsSingleton() throws Exception { + setStaticField("test", null); + setStaticField("singletonTestSerialiser", newTestSerialiserInstance()); + + TestSerialiser.exit(); + + Assert.assertNull(getStaticField("singletonTestSerialiser")); + } + + @Test + public void writethis_WhenTestStreamIsNull_DoesNotThrow() throws Exception { + setStaticField("test", null); + + Method writethis = TestSerialiser.class.getDeclaredMethod("writethis", TaggableBase.class); + writethis.setAccessible(true); + writethis.invoke(null, new TaggableBase()); + } + + private TestSerialiser newTestSerialiserInstance() throws Exception { + Constructor constructor = TestSerialiser.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private Object getStaticField(String fieldName) throws Exception { + Field field = TestSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(null); + } + + private void setStaticField(String fieldName, Object value) throws Exception { + Field field = TestSerialiser.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java b/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java index 79ae9d22e..bbde00318 100644 --- a/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java +++ b/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java @@ -64,6 +64,28 @@ public class AndroidLogcatOracle implements Oracle { private static final AtomicInteger SEQUENCE_COUNTER = new AtomicInteger(0); + // Pattern list for dynamic normalization + // 02-09 08:59:33.844 17550 17575 E Accessibility exception content... + private static final Pattern THREADTIME_PATTERN = Pattern.compile( + "^\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\s+\\d+\\s+\\d+\\s+([VDIWEAF])\\s+([^:]+):\\s*(.*)$" + ); + private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d+"); + private static final Pattern NORMALIZABLE_NUMBER_PATTERN = Pattern.compile("(? detectRegexMatches(List lines, String regex) { return matches; } - // logcat threadtime format: - // 02-09 08:59:33.844 17550 17575 E Accessibility exception content... - private final Pattern THREADTIME_PATTERN = Pattern.compile( - "^\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\s+\\d+\\s+\\d+\\s+([VDIWEAF])\\s+([^:]+):\\s*(.*)$" - ); - private String normalizeThreadtimeLine(String line) { - if (line == null) return ""; + if (line == null) { + return ""; + } line = line.trim(); Matcher m = THREADTIME_PATTERN.matcher(line); if (!m.matches()) { - return normalizeNumbers(line.replaceAll("\\s+", " ")); + return normalizeNumbers(normalizeDynamicObjectIdentities(normalizeAndroidPaths(line.replaceAll("\\s+", " ")))); } String tag = m.group(2).trim(); - String msg = normalizeNumbers(m.group(3).trim().replaceAll("\\s+", " ")); + String msg = normalizeNumbers( + normalizeDynamicObjectIdentities( + normalizeAndroidPaths(m.group(3).trim().replaceAll("\\s+", " ")) + ) + ); return tag + ": " + msg; } + private String normalizeDynamicObjectIdentities(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + + return JAVA_OBJECT_IDENTITY_PATTERN.matcher(text).replaceAll("@"); + } + + private String normalizeAndroidPaths(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + + Matcher matcher = ANDROID_ABSOLUTE_PATH_PATTERN.matcher(text); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String normalizedPath = normalizeAndroidPath(matcher.group(1)); + matcher.appendReplacement(sb, Matcher.quoteReplacement(normalizedPath)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + private String normalizeAndroidPath(String path) { + String[] segments = path.split("/"); + StringBuilder normalized = new StringBuilder(); + + for (String segment : segments) { + if (segment.isEmpty()) { + normalized.append("/"); + continue; + } + + normalized.append(normalizePathSegment(segment)).append("/"); + } + + if (normalized.length() > 1 && normalized.charAt(normalized.length() - 1) == '/') { + normalized.setLength(normalized.length() - 1); + } + + return normalized.toString(); + } + + private String normalizePathSegment(String segment) { + if (segment == null || segment.isEmpty()) { + return ""; + } + + if (isStablePathSegment(segment)) { + return segment; + } + + if (PACKAGE_SEGMENT_PATTERN.matcher(segment).matches()) { + return ""; + } + + if (UUID_SEGMENT_PATTERN.matcher(segment).matches()) { + return ""; + } + + if (HEX_OR_HASH_SEGMENT_PATTERN.matcher(segment).matches()) { + return ""; + } + + if (NUMBER_PATTERN.matcher(segment).matches()) { + return ""; + } + + int dotIndex = segment.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < segment.length() - 1) { + String name = segment.substring(0, dotIndex); + String extension = segment.substring(dotIndex); + if (isDynamicFileName(name)) { + return "" + extension; + } + return "" + extension; + } + + int underscoreIndex = segment.indexOf('_'); + if (underscoreIndex > 0 && underscoreIndex < segment.length() - 1) { + String prefix = segment.substring(0, underscoreIndex); + String suffix = segment.substring(underscoreIndex + 1); + if (isDynamicFileName(suffix)) { + return prefix + "_"; + } + } + + return ""; + } + + private boolean isStablePathSegment(String segment) { + switch (segment) { + case "data": + case "user": + case "cache": + case "files": + case "shared_prefs": + case "databases": + case "lib": + case "storage": + case "emulated": + case "sdcard": + case "mnt": + case "system": + case "vendor": + case "product": + case "apex": + return true; + default: + return false; + } + } + + private boolean isDynamicFileName(String value) { + if (value == null || value.isEmpty()) { + return true; + } + + return NUMBER_PATTERN.matcher(value).matches() + || HEX_OR_HASH_SEGMENT_PATTERN.matcher(value).matches() + || UUID_SEGMENT_PATTERN.matcher(value).matches(); + } + private String normalizeNumbers(String text) { if (text == null || text.isEmpty()) { return ""; } - Matcher matcher = Pattern.compile("\\d+").matcher(text); + Matcher matcher = NORMALIZABLE_NUMBER_PATTERN.matcher(text); StringBuffer sb = new StringBuffer(); while (matcher.find()) { String num = matcher.group(); diff --git a/testar/src/org/testar/reporting/HtmlReporter.java b/testar/src/org/testar/reporting/HtmlReporter.java index 54ff6c473..421691200 100644 --- a/testar/src/org/testar/reporting/HtmlReporter.java +++ b/testar/src/org/testar/reporting/HtmlReporter.java @@ -125,6 +125,12 @@ public void addState(State state) htmlReportUtil.addContent(htmlStateURL); } + // Add state feedback if exists + if(!state.get(Tags.StateFeedback, "").isEmpty()) { + String feedback = StringEscapeUtils.escapeHtml(state.get(Tags.StateFeedback, "")); + htmlReportUtil.addContent("

State Feedback: " + feedback + "

"); + } + // Add state screenshot String altText = "screenshot: state=" + innerLoopCounter + ", ConcreteID=" + concreteID+", AbstractID=" + abstractID; htmlReportUtil.addParagraph("\"""); diff --git a/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java b/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java index d4b971b78..575eb05be 100644 --- a/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java +++ b/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java @@ -215,6 +215,136 @@ public void generateModeVerdict_KeepsHttpStatusCodes() { } } + @Test + public void generateModeVerdict_NormalizesDynamicAndroidPaths() { + OutputStructure.logsOutputDir = Path.of("target").toString(); + OutputStructure.startInnerLoopDateString = "YYYY-MM-DD_hh-mm-ss"; + OutputStructure.executedSUTname = "test-sut"; + + Settings settings = buildSettings(RuntimeControlsProtocol.Modes.Generate, "(?i)(.*Exception.*)"); + AndroidLogcatOracle androidLogcatOracle = new AndroidLogcatOracle(settings); + State state = Mockito.mock(State.class); + + String line = "07-06 11:48:55.095 29813 29884 E BitmapFactory: Unable to decode file: java.io.FileNotFoundException: " + + "/data/user/0/com.example.app/cache/sentry/83a56134754ad7e27d5f94754e5a842865257057/" + + "replay_053d15c452f042f9a7049bb22a6860ca/1783338523682.jpg: open failed: ENOENT (No such file or directory)"; + + try (MockedStatic mocked = Mockito.mockStatic(AndroidAppiumFramework.class)) { + mocked.when(AndroidAppiumFramework::getAppPackageFromCapabilitiesOrCurrent).thenReturn("org.testar.app"); + mocked.when(() -> AndroidAppiumFramework.dumpLogcatThreadtimeForPackage("org.testar.app")) + .thenReturn(line); + + androidLogcatOracle.initialize(); + List verdicts = androidLogcatOracle.getVerdicts(state); + Assert.assertEquals(1, verdicts.size()); + Verdict verdict = verdicts.get(0); + + String expected = "Suspicious Android logcat line(s) detected " + + "BitmapFactory: Unable to decode file: java.io.FileNotFoundException: " + + "/data/user///cache///replay_/.jpg: " + + "open failed: ENOENT (No such file or directory)"; + Assert.assertEquals(expected, verdict.info()); + } + } + + @Test + public void generateModeVerdict_DeduplicatesDifferentDynamicAndroidPaths() { + OutputStructure.logsOutputDir = Path.of("target").toString(); + OutputStructure.startInnerLoopDateString = "YYYY-MM-DD_hh-mm-ss"; + OutputStructure.executedSUTname = "test-sut"; + + Settings settings = buildSettings(RuntimeControlsProtocol.Modes.Generate, "(?i)(.*Exception.*)"); + AndroidLogcatOracle androidLogcatOracle = new AndroidLogcatOracle(settings); + State state = Mockito.mock(State.class); + + String line1 = "07-06 11:48:55.095 29813 29884 E BitmapFactory: Unable to decode file: java.io.FileNotFoundException: " + + "/data/user/0/com.example.app/cache/sentry/83a56134754ad7e27d5f94754e5a842865257057/" + + "replay_053d15c452f042f9a7049bb22a6860ca/1783338523682.jpg: open failed: ENOENT (No such file or directory)"; + String line2 = "07-06 11:48:56.095 29813 29884 E BitmapFactory: Unable to decode file: java.io.FileNotFoundException: " + + "/data/user/0/com.example.app/cache/sentry/9f3d44b21234ad7e27d5f94754e5a842812345678/" + + "replay_77aa22bb33cc44dd55ee66ff77889900/1888888888888.jpg: open failed: ENOENT (No such file or directory)"; + + try (MockedStatic mocked = Mockito.mockStatic(AndroidAppiumFramework.class)) { + mocked.when(AndroidAppiumFramework::getAppPackageFromCapabilitiesOrCurrent).thenReturn("org.testar.app"); + mocked.when(() -> AndroidAppiumFramework.dumpLogcatThreadtimeForPackage("org.testar.app")) + .thenReturn(line1 + "\n" + line2); + + androidLogcatOracle.initialize(); + List verdicts = androidLogcatOracle.getVerdicts(state); + Assert.assertEquals(1, verdicts.size()); + Verdict verdict = verdicts.get(0); + + String expected = "Suspicious Android logcat line(s) detected " + + "BitmapFactory: Unable to decode file: java.io.FileNotFoundException: " + + "/data/user///cache///replay_/.jpg: " + + "open failed: ENOENT (No such file or directory)"; + Assert.assertEquals(expected, verdict.info()); + } + } + + @Test + public void generateModeVerdict_NormalizesDifferentAndroidPrivateStorageRoots() { + OutputStructure.logsOutputDir = Path.of("target").toString(); + OutputStructure.startInnerLoopDateString = "YYYY-MM-DD_hh-mm-ss"; + OutputStructure.executedSUTname = "test-sut"; + + Settings settings = buildSettings(RuntimeControlsProtocol.Modes.Generate, "(?i)(.*error.*)"); + AndroidLogcatOracle androidLogcatOracle = new AndroidLogcatOracle(settings); + State state = Mockito.mock(State.class); + + String line1 = "07-06 11:48:55.095 29813 29884 E SQLite: error opening db /data/data/com.example.app/databases/550e8400-e29b-41d4-a716-446655440000.db"; + String line2 = "07-06 11:48:56.095 29813 29884 E SQLite: error opening db /data/data/com.other.app/databases/123e4567-e89b-12d3-a456-426614174000.db"; + + try (MockedStatic mocked = Mockito.mockStatic(AndroidAppiumFramework.class)) { + mocked.when(AndroidAppiumFramework::getAppPackageFromCapabilitiesOrCurrent).thenReturn("org.testar.app"); + mocked.when(() -> AndroidAppiumFramework.dumpLogcatThreadtimeForPackage("org.testar.app")) + .thenReturn(line1 + "\n" + line2); + + androidLogcatOracle.initialize(); + List verdicts = androidLogcatOracle.getVerdicts(state); + Assert.assertEquals(1, verdicts.size()); + Verdict verdict = verdicts.get(0); + + String expected = "Suspicious Android logcat line(s) detected " + + "SQLite: error opening db /data/data//databases/.db"; + Assert.assertEquals(expected, verdict.info()); + } + } + + @Test + public void generateModeVerdict_NormalizesJavaObjectIdentitySuffixes() { + OutputStructure.logsOutputDir = Path.of("target").toString(); + OutputStructure.startInnerLoopDateString = "YYYY-MM-DD_hh-mm-ss"; + OutputStructure.executedSUTname = "test-sut"; + + Settings settings = buildSettings(RuntimeControlsProtocol.Modes.Generate, "(?i)(.*Integrations.*)"); + AndroidLogcatOracle androidLogcatOracle = new AndroidLogcatOracle(settings); + State state = Mockito.mock(State.class); + + String line1 = "07-06 11:48:55.095 29813 29884 E RNSentry: Native Integrations " + + "'[io.sentry.UncaughtExceptionHandlerIntegration@6d8322a, " + + "io.sentry.android.replay.ReplayIntegration@5c9d785]'"; + String line2 = "07-06 11:48:56.095 29813 29884 E RNSentry: Native Integrations " + + "'[io.sentry.UncaughtExceptionHandlerIntegration@123abcd, " + + "io.sentry.android.replay.ReplayIntegration@8de45f6]'"; + + try (MockedStatic mocked = Mockito.mockStatic(AndroidAppiumFramework.class)) { + mocked.when(AndroidAppiumFramework::getAppPackageFromCapabilitiesOrCurrent).thenReturn("org.testar.app"); + mocked.when(() -> AndroidAppiumFramework.dumpLogcatThreadtimeForPackage("org.testar.app")) + .thenReturn(line1 + "\n" + line2); + + androidLogcatOracle.initialize(); + List verdicts = androidLogcatOracle.getVerdicts(state); + Assert.assertEquals(1, verdicts.size()); + Verdict verdict = verdicts.get(0); + + String expected = "Suspicious Android logcat line(s) detected " + + "RNSentry: Native Integrations '[io.sentry.UncaughtExceptionHandlerIntegration@, " + + "io.sentry.android.replay.ReplayIntegration@]'"; + Assert.assertEquals(expected, verdict.info()); + } + } + private Settings buildSettings(RuntimeControlsProtocol.Modes mode, String regex) { List> tags = new ArrayList<>(); tags.add(Pair.from(ConfigTags.Mode, mode)); diff --git a/webdriver/resources/web-extension/js/testar.canvas.js b/webdriver/resources/web-extension/js/testar.canvas.js index 3d7b9fc23..a4222752a 100644 --- a/webdriver/resources/web-extension/js/testar.canvas.js +++ b/webdriver/resources/web-extension/js/testar.canvas.js @@ -14,7 +14,12 @@ function addCanvasTestar() { // Create canvas, get reference to context testar_canvas = document.createElement('canvas'); testar_canvas.id = 'testar_canvas'; - document.body.appendChild(testar_canvas); + testar_canvas.style.position = 'fixed'; + testar_canvas.style.margin = '0px'; + testar_canvas.style.padding = '0px'; + testar_canvas.style.border = '0px'; + testar_canvas.style.pointerEvents = 'none'; + testar_canvas.style.zIndex = '2147483647'; testarCtx = testar_canvas.getContext('2d'); // Set canvas to complete viewport @@ -23,6 +28,12 @@ function addCanvasTestar() { // Make sure canvas keeps size of viewport on resize or scroll window.addEventListener('resize', resizeCanvasTestar, true); window.addEventListener('scroll', resizeCanvasTestar, true); + new MutationObserver(ensureCanvasOnTop).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['open', 'popover'] + }); ensureCanvasOnTop(); return typeof testar_canvas; @@ -33,12 +44,32 @@ function addCanvasTestar() { * will try to get their element the highest z-index */ function ensureCanvasOnTop() { - var lengths = Array.from(document.querySelectorAll('body *')) - .map(a => parseFloat(window.getComputedStyle(a).zIndex)) - .filter(a => !isNaN(a)); - var maxIndex = Math.max.apply(null, lengths); - if (testar_canvas.style.zIndex < maxIndex) { - testar_canvas.style.zIndex = maxIndex + 1; + if (typeof testar_canvas !== 'object') { + return; + } + + var canvasHost = document.body; + + try { + var popoverHost = document.querySelector(':popover-open'); + if (popoverHost) { + canvasHost = popoverHost; + } + } catch (error) { + } + + if (canvasHost === document.body) { + try { + var modalHost = document.querySelector(':modal'); + if (modalHost) { + canvasHost = modalHost; + } + } catch (error) { + } + } + + if (testar_canvas.parentNode !== canvasHost || canvasHost.lastElementChild !== testar_canvas) { + canvasHost.appendChild(testar_canvas); } } diff --git a/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java b/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java index db6483cc5..e46d11b89 100644 --- a/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java +++ b/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java @@ -394,8 +394,8 @@ protected void fillScrollValues() { public boolean visibleAt(double x, double y) { int scrollLeft = (root == null) ? 0 : (int) root.scrollLeft; - int scrollHeight = (root == null) ? 0 : (int) root.scrollHeight; - return rect != null && rect.contains(x - scrollLeft, y - scrollHeight); + int scrollTop = (root == null) ? 0 : (int) root.scrollTop; + return rect != null && rect.contains(x - scrollLeft, y - scrollTop); } public boolean visibleAt(double x, double y, boolean obscuredByChildFeature) {