diff --git a/CHANGELOG b/CHANGELOG index 792084130..d1e48e4b4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,16 @@ +#TESTAR v2.8.16 (15-Sep-2026) +- Bump org.seleniumhq.selenium:selenium-java from 4.48.0 to 4.49.0 +- Update devtools dependencies to v153 +- Add state feedback tag +- Add log, screenshots, and test serialiser null guards +- Calculate WdElement visibleAt logic with scrollTop +- Extend web ensureCanvasOnTop logic +- Extend AndroidLogcatOracle to normalize file paths and object IDs +- Update android actions toString description, which constitutes the action's concrete ID. This also fixes an issue with HTML report action screenshots +- Delete unused AndroidActionPinch +- Catch potential android driver initialization and non responsive issues to avoid breaking TESTAR execution + + #TESTAR v2.8.15 (14-Sep-2026) - Bump org.seleniumhq.selenium:selenium-java from 4.46.0 to 4.48.0 - Update devtools dependencies to v152 diff --git a/VERSION b/VERSION index 5c116b90b..2510a6d7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.15 \ No newline at end of file +2.8.16 \ No newline at end of file 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/AndroidStateBuilder.java b/android/src/org/testar/monkey/alayer/android/AndroidStateBuilder.java index 22f48d3b5..85cd2c8b3 100644 --- a/android/src/org/testar/monkey/alayer/android/AndroidStateBuilder.java +++ b/android/src/org/testar/monkey/alayer/android/AndroidStateBuilder.java @@ -57,11 +57,7 @@ public State apply(SUT system) throws StateBuildException { // If the driver became unresponsive during non-state fetcher calls like actions or logact if (AndroidAppiumFramework.isDriverUnresponsive()) { AndroidAppiumFramework.resetDriverUnresponsive(); - AndroidRootElement rootElement = AndroidStateFetcher.buildRoot(system); - AndroidState androidState = new AndroidState(rootElement); - androidState.set(Tags.Role, Roles.Process); - androidState.set(Tags.NotResponding, true); - return androidState; + return buildNotRespondingState(""); } Future future = executor.submit(new AndroidStateFetcher(system)); @@ -69,12 +65,9 @@ public State apply(SUT system) throws StateBuildException { // If the driver became unresponsive during state fetch calls if (AndroidAppiumFramework.isDriverUnresponsive()) { + String stateFeedback = state.get(Tags.StateFeedback, ""); AndroidAppiumFramework.resetDriverUnresponsive(); - AndroidRootElement rootElement = AndroidStateFetcher.buildRoot(system); - AndroidState androidState = new AndroidState(rootElement); - androidState.set(Tags.Role, Roles.Process); - androidState.set(Tags.NotResponding, true); - return androidState; + return buildNotRespondingState(stateFeedback); } return state; @@ -84,12 +77,24 @@ public State apply(SUT system) throws StateBuildException { throw new StateBuildException(e.getMessage()); } catch (TimeoutException e) { - AndroidRootElement rootElement = AndroidStateFetcher.buildRoot(system); - AndroidState androidState = new AndroidState(rootElement); - androidState.set(Tags.Role, Roles.Process); - androidState.set(Tags.NotResponding, true); + return buildNotRespondingState(""); + } + } - return androidState; + private AndroidState buildNotRespondingState(String stateFeedback) { + AndroidRootElement rootElement = new AndroidRootElement(); + rootElement.timeStamp = System.currentTimeMillis(); + rootElement.pid = -1; + rootElement.isRunning = false; + rootElement.isForeground = false; + + AndroidState androidState = new AndroidState(rootElement); + androidState.set(Tags.Role, Roles.Process); + androidState.set(Tags.NotResponding, true); + if (stateFeedback != null && !stateFeedback.isEmpty()) { + androidState.set(Tags.StateFeedback, stateFeedback); } + + return androidState; } } 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..7d1195fc6 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) + + ",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..e635a8018 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,9 +31,9 @@ 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; import org.testar.monkey.alayer.android.enums.AndroidTags; public class AndroidActionLongClick extends TaggableBase implements Action { @@ -47,13 +47,13 @@ public class AndroidActionLongClick extends TaggableBase implements Action { private final String text; public AndroidActionLongClick(State state, Widget w) { - this.set(Tags.Role, AndroidRoles.AndroidWidget); + this.set(Tags.Role, ActionRoles.LeftClickAt); 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 +74,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) + + ",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..05bf4bb61 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: @@ -31,31 +31,37 @@ 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; import org.testar.monkey.alayer.android.enums.AndroidTags; 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.set(Tags.Role, ActionRoles.Drag); 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) + + ",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..aed26fa6a 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) + + ",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..0c48f67d1 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidBackAction.java @@ -31,17 +31,19 @@ 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; 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.set(Tags.Role, ActionRoles.Action); 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) + + ",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..2309aa217 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionCall.java @@ -31,17 +31,19 @@ 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; 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.set(Tags.Role, ActionRoles.Action); 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) + + ",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..c07c1b7e9 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionOrientation.java @@ -31,17 +31,19 @@ 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; 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.set(Tags.Role, ActionRoles.Action); 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) + + ",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..76b39a16e 100644 --- a/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java +++ b/android/src/org/testar/monkey/alayer/android/actions/AndroidSystemActionText.java @@ -31,17 +31,19 @@ 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; 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.set(Tags.Role, ActionRoles.Action); 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) + + ",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..c97cdd5ca --- /dev/null +++ b/android/test/org/testar/monkey/alayer/android/TestAndroidDriverNullGuard.java @@ -0,0 +1,154 @@ +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.android.AndroidDriver; +import io.appium.java_client.appmanagement.ApplicationState; +import org.openqa.selenium.WebDriverException; +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 buildState_WhenPageSourceFails_PreservesFeedbackAndClearsUnresponsiveFlag() throws Exception { + AndroidDriver driver = Mockito.mock(AndroidDriver.class); + Mockito.when(driver.getPageSource()).thenThrow(new WebDriverException("page source timeout")); + setStaticDriver(driver); + + SUT system = Mockito.mock(SUT.class); + Mockito.when(system.isRunning()).thenReturn(true); + Mockito.when(system.get(Tags.PID, (long) -1)).thenReturn((long) -1); + + State state = new AndroidStateBuilder(1.0).apply(system); + + Assert.assertTrue(state.get(Tags.NotResponding, false)); + Assert.assertTrue(state.get(Tags.StateFeedback, "").contains("page source timeout")); + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + Mockito.verify(system, Mockito.times(1)).isRunning(); + } + + @Test + public void buildState_WhenAlreadyUnresponsive_DoesNotQuerySystemRunning() throws Exception { + setStaticDriver(null); + AndroidAppiumFramework.getActivity(); + + SUT system = Mockito.mock(SUT.class); + Mockito.when(system.isRunning()).thenAnswer(invocation -> { + AndroidAppiumFramework.getActivity(); + return true; + }); + + State state = new AndroidStateBuilder(1.0).apply(system); + + Assert.assertTrue(state.get(Tags.NotResponding, false)); + Assert.assertFalse(AndroidAppiumFramework.isDriverUnresponsive()); + Mockito.verify(system, Mockito.never()).isRunning(); + } + + @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 deleted file mode 100644 index 0a9cac3ce..000000000 --- a/android/test/org/testar/monkey/alayer/android/actions/BuildAndroidActions.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.testar.monkey.alayer.android.actions; - -import org.junit.Test; -import org.junit.Before; -import org.testar.monkey.Assert; -import org.testar.monkey.alayer.Action; -import org.testar.monkey.alayer.Rect; -import org.testar.monkey.alayer.Tags; -import org.testar.monkey.alayer.android.enums.AndroidTags; -import org.testar.stub.StateStub; -import org.testar.stub.WidgetStub; - -public class BuildAndroidActions { - - private static StateStub state; - private static WidgetStub widget; - - private static String statePath = "[0]"; - private static String widgetPath = "[0,0,1]"; - - @Before - public void prepare_widget_and_state() { - state = new StateStub(); - state.set(AndroidTags.AndroidXpath, statePath); - widget = new WidgetStub(); - state.addChild(widget); - widget.setParent(state); - - widget.set(Tags.Shape, Rect.fromCoordinates(0, 0, 100, 100)); - - widget.set(AndroidTags.AndroidText, "TextValue"); - widget.set(AndroidTags.AndroidAccessibilityId, "AccessibilityIdValue"); - widget.set(AndroidTags.AndroidClassName, "ClassNameValue"); - widget.set(AndroidTags.AndroidXpath, widgetPath); - } - - @Test - public void buildAndroidActionClick() { - Action androidClick = new AndroidActionClick(state, widget); - // Verify Action <-> Widget mapping - Assert.notNull(androidClick.get(Tags.OriginWidget)); - Assert.isTrue(androidClick.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); - } - - @Test - public void buildAndroidActionLongClick() { - Action androidLongClick = new AndroidActionLongClick(state, widget); - // Verify Action <-> Widget mapping - Assert.notNull(androidLongClick.get(Tags.OriginWidget)); - 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); - // Verify Action <-> Widget mapping - Assert.notNull(androidScroll.get(Tags.OriginWidget)); - Assert.isTrue(androidScroll.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); - } - - @Test - public void buildAndroidActionType() { - Action androidType = new AndroidActionType(state, widget, "TextToType"); - // Verify Action <-> Widget mapping - Assert.notNull(androidType.get(Tags.OriginWidget)); - Assert.isTrue(androidType.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); - Assert.isTrue(androidType.get(Tags.InputText).equals("TextToType")); - } - - @Test - public void buildAndroidBackAction() { - Action androidBack = new AndroidBackAction(state); - // Verify Action <-> Widget mapping - Assert.notNull(androidBack.get(Tags.OriginWidget)); - Assert.isTrue(androidBack.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); - } - - @Test - public void buildAndroidSystemActionCall() { - Action androidSystemCall = new AndroidSystemActionCall(state); - // Verify Action <-> Widget mapping - Assert.notNull(androidSystemCall.get(Tags.OriginWidget)); - Assert.isTrue(androidSystemCall.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); - } - - @Test - public void buildAndroidSystemActionOrientation() { - Action androidSystemOrientation = new AndroidSystemActionOrientation(state); - // Verify Action <-> Widget mapping - Assert.notNull(androidSystemOrientation.get(Tags.OriginWidget)); - Assert.isTrue(androidSystemOrientation.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); - } - - @Test - public void buildAndroidSystemActionText() { - Action androidSystemText = new AndroidSystemActionText(state); - // Verify Action <-> Widget mapping - Assert.notNull(androidSystemText.get(Tags.OriginWidget)); - Assert.isTrue(androidSystemText.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); - } -} 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/android/test/org/testar/monkey/alayer/android/actions/TestBuildAndroidActions.java b/android/test/org/testar/monkey/alayer/android/actions/TestBuildAndroidActions.java new file mode 100644 index 000000000..48a6fbbad --- /dev/null +++ b/android/test/org/testar/monkey/alayer/android/actions/TestBuildAndroidActions.java @@ -0,0 +1,126 @@ +package org.testar.monkey.alayer.android.actions; + +import org.junit.Test; +import org.junit.Before; +import org.junit.Assert; +import org.testar.monkey.alayer.Action; +import org.testar.monkey.alayer.Rect; +import org.testar.monkey.alayer.Tags; +import org.testar.monkey.alayer.actions.ActionRoles; +import org.testar.monkey.alayer.android.enums.AndroidTags; +import org.testar.stub.StateStub; +import org.testar.stub.WidgetStub; + +public class TestBuildAndroidActions { + + private static StateStub state; + private static WidgetStub widget; + + private static String statePath = "[0]"; + private static String widgetPath = "[0,0,1]"; + + @Before + public void prepare_widget_and_state() { + state = new StateStub(); + state.set(AndroidTags.AndroidXpath, statePath); + widget = new WidgetStub(); + state.addChild(widget); + widget.setParent(state); + + widget.set(Tags.Shape, Rect.fromCoordinates(0, 0, 100, 100)); + + widget.set(AndroidTags.AndroidText, "TextValue"); + widget.set(AndroidTags.AndroidAccessibilityId, "AccessibilityIdValue"); + widget.set(AndroidTags.AndroidClassName, "ClassNameValue"); + widget.set(AndroidTags.AndroidXpath, widgetPath); + } + + @Test + public void buildAndroidActionClick() { + Action androidClick = new AndroidActionClick(state, widget); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidClick.get(Tags.OriginWidget)); + Assert.assertTrue(androidClick.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); + // Verify action role + Assert.assertEquals(ActionRoles.LeftClickAt, androidClick.get(Tags.Role)); + Assert.assertTrue(androidClick.toParametersString().startsWith("role=" + ActionRoles.LeftClickAt)); + } + + @Test + public void buildAndroidActionLongClick() { + Action androidLongClick = new AndroidActionLongClick(state, widget); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidLongClick.get(Tags.OriginWidget)); + Assert.assertTrue(androidLongClick.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); + // Verify action role + Assert.assertEquals(ActionRoles.LeftClickAt, androidLongClick.get(Tags.Role)); + Assert.assertTrue(androidLongClick.toParametersString().startsWith("role=" + ActionRoles.LeftClickAt)); + } + + @Test + public void buildAndroidActionScroll() { + Action androidScroll = new AndroidActionScroll(state, widget); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidScroll.get(Tags.OriginWidget)); + Assert.assertTrue(androidScroll.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); + // Verify action role + Assert.assertEquals(ActionRoles.Drag, androidScroll.get(Tags.Role)); + Assert.assertTrue(androidScroll.toParametersString().startsWith("role=" + ActionRoles.Drag)); + } + + @Test + public void buildAndroidActionType() { + Action androidType = new AndroidActionType(state, widget, "TextToType"); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidType.get(Tags.OriginWidget)); + Assert.assertTrue(androidType.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(widgetPath)); + Assert.assertTrue(androidType.get(Tags.InputText).equals("TextToType")); + // Verify action role + Assert.assertEquals(ActionRoles.ClickTypeInto, androidType.get(Tags.Role)); + Assert.assertTrue(androidType.toParametersString().startsWith("role=" + ActionRoles.ClickTypeInto)); + } + + @Test + public void buildAndroidBackAction() { + Action androidBack = new AndroidBackAction(state); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidBack.get(Tags.OriginWidget)); + Assert.assertTrue(androidBack.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); + // Verify action role + Assert.assertEquals(ActionRoles.Action, androidBack.get(Tags.Role)); + Assert.assertTrue(androidBack.toParametersString().startsWith("role=" + ActionRoles.Action)); + } + + @Test + public void buildAndroidSystemActionCall() { + Action androidSystemCall = new AndroidSystemActionCall(state); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidSystemCall.get(Tags.OriginWidget)); + Assert.assertTrue(androidSystemCall.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); + // Verify action role + Assert.assertEquals(ActionRoles.Action, androidSystemCall.get(Tags.Role)); + Assert.assertTrue(androidSystemCall.toParametersString().startsWith("role=" + ActionRoles.Action)); + } + + @Test + public void buildAndroidSystemActionOrientation() { + Action androidSystemOrientation = new AndroidSystemActionOrientation(state); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidSystemOrientation.get(Tags.OriginWidget)); + Assert.assertTrue(androidSystemOrientation.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); + // Verify action role + Assert.assertEquals(ActionRoles.Action, androidSystemOrientation.get(Tags.Role)); + Assert.assertTrue(androidSystemOrientation.toParametersString().startsWith("role=" + ActionRoles.Action)); + } + + @Test + public void buildAndroidSystemActionText() { + Action androidSystemText = new AndroidSystemActionText(state); + // Verify Action <-> Widget mapping + Assert.assertNotNull(androidSystemText.get(Tags.OriginWidget)); + Assert.assertTrue(androidSystemText.get(Tags.OriginWidget).get(AndroidTags.AndroidXpath).equals(statePath)); + // Verify action role + Assert.assertEquals(ActionRoles.Action, androidSystemText.get(Tags.Role)); + Assert.assertTrue(androidSystemText.toParametersString().startsWith("role=" + ActionRoles.Action)); + } +} diff --git a/build.gradle b/build.gradle index 8ab32e400..cd592db27 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ subprojects { implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.15.0' implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.15.0' // https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java - implementation group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '4.48.0' + implementation group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '4.49.0' // https://mvnrepository.com/artifact/io.github.bonigarcia/webdrivermanager implementation group: 'io.github.bonigarcia', name: 'webdrivermanager', version: '6.3.4' // https://mvnrepository.com/artifact/io.appium/java-client 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..3882c2fd8 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; @@ -71,7 +74,6 @@ public static void finish(){ alive = false; } - // by Sebastian Bauersfeld public static enum LogLevel{ Critical(0), Info(1), Debug(2); final int significance; @@ -100,12 +102,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 +140,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,18 +152,23 @@ 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"); } } } - } catch (Exception e) {} // log may be set to null when we try to sync on it - //System.out.println("LogManager exited"); + } catch (Exception e) {} // log may be set to null when we try to sync on it log = null; } } diff --git a/core/src/org/testar/serialisation/ScreenshotSerialiser.java b/core/src/org/testar/serialisation/ScreenshotSerialiser.java index fd04e20b1..e02c2e167 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,8 +31,12 @@ 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.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -44,10 +48,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; @@ -109,6 +114,10 @@ public void run(){ synchronized(scrshotSavingQueue){ r = scrshotSavingQueue.removeFirst(); } + if (r.scrshot == null) { + logger.error("ScreenshotSerialiser skipped saving a screenshot because the AWTCanvas is null."); + continue; + } try { // Write to a temp file, then atomically move/replace it to the final name. Path finalPath = Paths.get(r.scrshotPath); @@ -118,7 +127,7 @@ public void run(){ Files.move(tmpPath, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (java.nio.file.AtomicMoveNotSupportedException e) { + } catch (AtomicMoveNotSupportedException e) { // fall back to a regular replace if ATOMIC_MOVE not available (e.g., some filesystems) Files.move(tmpPath, finalPath, StandardCopyOption.REPLACE_EXISTING); } @@ -128,9 +137,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(); } } @@ -149,6 +164,10 @@ public static String saveActionshot(String stateID, String actionID, final AWTCa } private static void savethis(String scrshotPath, AWTCanvas scrshot){ + if (scrshot == null) { + logger.error("ScreenshotSerialiser skipped queuing a screenshot because the AWTCanvas is null."); + return; + } if (alive){ synchronized(scrshotSavingQueue){ scrshotSavingQueue.add(new ScrshotRecord(scrshotPath,scrshot)); @@ -159,11 +178,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..5477c7a21 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,25 @@ 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(); } catch (IOException e) { - LogSerialiser.log("I/O exception closing serialisation of test file!\n", LogSerialiser.LogLevel.Critical); + LogSerialiser.log("I/O exception flushing test file!\n", LogSerialiser.LogLevel.Critical); } - } - synchronized(test){ - //System.out.println("TestSerialiser finished"); + try { + currentTest.close(); + } catch (IOException e) { + LogSerialiser.log("I/O exception closing serialisation of test file!\n", LogSerialiser.LogLevel.Critical); + } + synchronized(currentTest){ + singletonTestSerialiser = null; + currentTest.notifyAll(); + } + } else { + logger.error("TestSerialiser finished without an active ObjectOutputStream."); singletonTestSerialiser = null; - test.notifyAll(); } } @@ -127,6 +135,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,18 +159,23 @@ 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"); } } } - } catch (Exception e) {} // test may be set to null when we try to sync on it - //System.out.println("TestSerialisationManager exited"); + } catch (Exception e) {} // test may be set to null when we try to sync on it test = null; } } 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..a784bd915 --- /dev/null +++ b/core/test/org/testar/serialisation/ScreenshotSerialiserNullGuardTest.java @@ -0,0 +1,60 @@ +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")); + } + + @Test + public void saveStateshot_WhenCanvasIsNull_DoesNotQueueScreenshot() throws Exception { + setStaticField("alive", true); + setStaticField("scrshotOutputFolder", "build/null-screenshot-" + System.nanoTime()); + setStaticField("testSequenceFolder", "sequence"); + + ScreenshotSerialiser.saveStateshot("state-id", null); + + Assert.assertEquals(0, ScreenshotSerialiser.queueLength()); + } + + 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..04bbf596c --- /dev/null +++ b/core/test/org/testar/serialisation/TestSerialiserNullGuardTest.java @@ -0,0 +1,94 @@ +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.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.io.IOException; +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()); + } + + @Test + public void run_WhenTestStreamIsValid_ClosesStreamOnce() throws Exception { + CountingObjectOutputStream outputStream = new CountingObjectOutputStream(); + TestSerialiser serialiser = newTestSerialiserInstance(); + setStaticField("test", outputStream); + setStaticField("singletonTestSerialiser", serialiser); + setStaticField("alive", false); + + serialiser.run(); + + Assert.assertEquals(1, outputStream.getCloseCount()); + } + + 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); + } + + private static class CountingObjectOutputStream extends ObjectOutputStream { + + private int closeCount; + + private CountingObjectOutputStream() throws IOException { + super(new ByteArrayOutputStream()); + } + + @Override + public void close() throws IOException { + closeCount++; + super.close(); + } + + private int getCloseCount() { + return closeCount; + } + } +} diff --git a/testar/resources/settings/webdriver_security_analysis/Protocol_webdriver_security_analysis.java b/testar/resources/settings/webdriver_security_analysis/Protocol_webdriver_security_analysis.java index a37a2d071..e3444a4ef 100644 --- a/testar/resources/settings/webdriver_security_analysis/Protocol_webdriver_security_analysis.java +++ b/testar/resources/settings/webdriver_security_analysis/Protocol_webdriver_security_analysis.java @@ -33,7 +33,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.devtools.DevTools; import org.openqa.selenium.devtools.HasDevTools; -import org.openqa.selenium.devtools.v152.network.Network; +import org.openqa.selenium.devtools.v153.network.Network; import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.remote.RemoteWebDriver; import org.testar.SutVisualization; diff --git a/testar/src/org/testar/monkey/Main.java b/testar/src/org/testar/monkey/Main.java index 85c064c26..4ccbc0783 100644 --- a/testar/src/org/testar/monkey/Main.java +++ b/testar/src/org/testar/monkey/Main.java @@ -65,7 +65,7 @@ public class Main { - public static final String TESTAR_VERSION = "v2.8.15 (14-Sep-2026)"; + public static final String TESTAR_VERSION = "v2.8.16 (15-Sep-2026)"; //public static final String TESTAR_DIR_PROPERTY = "DIRNAME"; //Use the OS environment to obtain TESTAR directory public static final String SETTINGS_FILE = "test.settings"; diff --git a/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java b/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java index 79ae9d22e..f7064abc2 100644 --- a/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java +++ b/testar/src/org/testar/oracles/log/AndroidLogcatOracle.java @@ -64,6 +64,35 @@ 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) { } for (String raw : lines) { - String normalized = normalizeThreadtimeLine(raw); + String unnormalizedMessage = stripThreadtimeLine(raw); try { - if (p.matcher(normalized).find()) { - matches.add(normalized); + if (p.matcher(unnormalizedMessage).find()) { + matches.add(normalizeThreadtimeLine(unnormalizedMessage)); } } catch (Exception ignored) { } @@ -184,35 +213,222 @@ private List 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 ""; + String message = stripThreadtimeLine(line); + return normalizeNumbers( + normalizeDynamicObjectIdentities( + normalizeAndroidPaths(message) + ) + ); + } + + private String stripThreadtimeLine(String line) { + if (line == null) { + return ""; + } line = line.trim(); Matcher m = THREADTIME_PATTERN.matcher(line); if (!m.matches()) { - return normalizeNumbers(line.replaceAll("\\s+", " ")); + return line.replaceAll("\\s+", " "); } String tag = m.group(2).trim(); - String msg = normalizeNumbers(m.group(3).trim().replaceAll("\\s+", " ")); + return tag + ": " + m.group(3).trim().replaceAll("\\s+", " "); + } + + 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(); + boolean withinVolatileDirectory = false; + boolean userIndexExpected = false; + boolean packageSegmentExpected = false; + String previousSegment = ""; + + for (String segment : segments) { + if (segment.isEmpty()) { + normalized.append("/"); + continue; + } + + boolean currentPackageSegment = packageSegmentExpected; + boolean currentUserIndex = userIndexExpected; + packageSegmentExpected = false; + userIndexExpected = false; + + normalized.append(normalizePathSegment( + segment, + withinVolatileDirectory, + currentPackageSegment, + currentUserIndex + )).append("/"); + + if ("user".equals(segment) || "emulated".equals(segment)) { + userIndexExpected = true; + } else if (currentUserIndex) { + packageSegmentExpected = true; + } + + if ("data".equals(segment) + && ("data".equals(previousSegment) || "Android".equals(previousSegment))) { + packageSegmentExpected = true; + } + + if (isVolatilePathSegment(segment)) { + withinVolatileDirectory = true; + } + previousSegment = segment; + } + + if (normalized.length() > 1 && normalized.charAt(normalized.length() - 1) == '/') { + normalized.setLength(normalized.length() - 1); + } - return tag + ": " + msg; + return normalized.toString(); + } + + private String normalizePathSegment( + String segment, + boolean withinVolatileDirectory, + boolean packageSegment, + boolean userIndex + ) { + if (segment == null || segment.isEmpty()) { + return ""; + } + + if (isStablePathSegment(segment)) { + return segment; + } + + if (packageSegment && 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 (TIMESTAMP_SEGMENT_PATTERN.matcher(segment).matches() + || (userIndex && NUMBER_PATTERN.matcher(segment).matches()) + || (withinVolatileDirectory && 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 segment; + } + + 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 + "_"; + } + } + + if (isMixedEntropySegment(segment)) { + return ""; + } + + return withinVolatileDirectory ? "" : segment; + } + + private boolean isVolatilePathSegment(String segment) { + switch (segment) { + case "cache": + case "code_cache": + case "tmp": + return true; + default: + return false; + } + } + + 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 false; + } + + return TIMESTAMP_SEGMENT_PATTERN.matcher(value).matches() + || (NUMBER_PATTERN.matcher(value).matches() && value.length() >= 4) + || HEX_OR_HASH_SEGMENT_PATTERN.matcher(value).matches() + || UUID_SEGMENT_PATTERN.matcher(value).matches() + || isMixedEntropySegment(value); + } + + private boolean isMixedEntropySegment(String value) { + return value != null && MIXED_ENTROPY_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(); - if (isHttpFailureStatus(num)) { + if (isHttpFailureStatus(num) + || isWithinAndroidPath(text, matcher.start(), matcher.end()) + || isMeaningfulNumber(text, matcher.start()) + || !isHighConfidenceDynamicNumber(text, matcher.start(), num)) { matcher.appendReplacement(sb, num); } else { matcher.appendReplacement(sb, ""); @@ -222,6 +438,30 @@ private String normalizeNumbers(String text) { return sb.toString(); } + private boolean isMeaningfulNumber(String text, int start) { + int contextStart = Math.max(0, start - 32); + String context = text.substring(contextStart, start); + return MEANINGFUL_NUMBER_CONTEXT_PATTERN.matcher(context).find(); + } + + private boolean isWithinAndroidPath(String text, int start, int end) { + Matcher pathMatcher = ANDROID_ABSOLUTE_PATH_PATTERN.matcher(text); + while (pathMatcher.find()) { + if (start >= pathMatcher.start(1) && end <= pathMatcher.end(1)) { + return true; + } + } + return false; + } + + private boolean isHighConfidenceDynamicNumber(String text, int start, String number) { + if (number.length() >= 4) { + return true; + } + + return start > 0 && (text.charAt(start - 1) == '@' || text.charAt(start - 1) == ':'); + } + private boolean isHttpFailureStatus(String num) { if (num.length() != 3) { return false; 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/src/org/testar/securityanalysis/oracles/HeaderAnalysisSecurityOracle.java b/testar/src/org/testar/securityanalysis/oracles/HeaderAnalysisSecurityOracle.java index 10c8d57fc..e2c8ed4b6 100644 --- a/testar/src/org/testar/securityanalysis/oracles/HeaderAnalysisSecurityOracle.java +++ b/testar/src/org/testar/securityanalysis/oracles/HeaderAnalysisSecurityOracle.java @@ -31,8 +31,8 @@ package org.testar.securityanalysis.oracles; import org.openqa.selenium.devtools.DevTools; -import org.openqa.selenium.devtools.v152.network.Network; -import org.openqa.selenium.devtools.v152.network.model.Headers; +import org.openqa.selenium.devtools.v153.network.Network; +import org.openqa.selenium.devtools.v153.network.model.Headers; import org.testar.monkey.alayer.Verdict; import org.testar.monkey.alayer.webdriver.WdDriver; import org.testar.securityanalysis.NetworkCollector; diff --git a/testar/src/org/testar/securityanalysis/oracles/SqlInjectionSecurityOracle.java b/testar/src/org/testar/securityanalysis/oracles/SqlInjectionSecurityOracle.java index 5789edc87..5221600e2 100644 --- a/testar/src/org/testar/securityanalysis/oracles/SqlInjectionSecurityOracle.java +++ b/testar/src/org/testar/securityanalysis/oracles/SqlInjectionSecurityOracle.java @@ -31,7 +31,7 @@ package org.testar.securityanalysis.oracles; import org.openqa.selenium.devtools.DevTools; -import org.openqa.selenium.devtools.v152.network.Network; +import org.openqa.selenium.devtools.v153.network.Network; import org.openqa.selenium.remote.RemoteWebDriver; import org.testar.monkey.alayer.*; import org.testar.monkey.alayer.actions.WdSecurityInjectionAction; diff --git a/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java b/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java index d4b971b78..79103839a 100644 --- a/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java +++ b/testar/test/org/testar/oracles/log/TestAndroidLogcatOracle.java @@ -89,6 +89,31 @@ public void generateModeVerdict_DetectsRegexAndReturnsSuspiciousLog() { } } + @Test + public void generateModeVerdict_MatchesRegexBeforeDynamicNormalization() { + OutputStructure.logsOutputDir = Path.of("target").toString(); + OutputStructure.startInnerLoopDateString = "YYYY-MM-DD_hh-mm-ss"; + OutputStructure.executedSUTname = "test-sut"; + + Settings settings = buildSettings(RuntimeControlsProtocol.Modes.Generate, "Exception @1:207875"); + AndroidLogcatOracle androidLogcatOracle = new AndroidLogcatOracle(settings); + State state = Mockito.mock(State.class); + + String line = "02-09 08:59:33.844 17550 17575 E ViewRootImpl: Exception @1:207875"; + + 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()); + Assert.assertTrue(verdicts.get(0).info().contains("ViewRootImpl: Exception @:")); + } + } + @Test public void generateModeVerdict_ProcessesOnlyNewLinesAcrossCalls() { OutputStructure.logsOutputDir = Path.of("target").toString(); @@ -215,6 +240,224 @@ 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_PreservesStableAndroidFilenames() { + 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 FileReader: Exception opening /data/data/com.example.app/files/reports/config.db"; + String line2 = "07-06 11:48:56.095 29813 29884 E FileReader: Exception opening /data/data/com.example.app/files/reports/settings-2024.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()); + Assert.assertEquals( + "Suspicious Android logcat line(s) detected " + + "FileReader: Exception opening /data/data//files/reports/config.db" + + " | FileReader: Exception opening /data/data//files/reports/settings-2024.db", + verdicts.get(0).info() + ); + } + } + + @Test + public void generateModeVerdict_PreservesMeaningfulNumericValues() { + 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 Worker: Exception code 1"; + String line2 = "07-06 11:48:56.095 29813 29884 E Worker: Exception code 2"; + + 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()); + Assert.assertEquals( + "Suspicious Android logcat line(s) detected Worker: Exception code 1" + + " | Worker: Exception code 2", + verdicts.get(0).info() + ); + } + } + + @Test + public void generateModeVerdict_DoesNotCollapseDistinctExceptions() { + 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 Worker: IllegalArgumentException: invalid state"; + String line2 = "07-06 11:48:56.095 29813 29884 E Worker: NullPointerException: missing state"; + + 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()); + Assert.assertTrue(verdicts.get(0).info().contains("IllegalArgumentException: invalid state")); + Assert.assertTrue(verdicts.get(0).info().contains("NullPointerException: missing state")); + } + } + + @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..f5910d0bd 100644 --- a/webdriver/resources/web-extension/js/testar.canvas.js +++ b/webdriver/resources/web-extension/js/testar.canvas.js @@ -23,6 +23,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 +39,49 @@ 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); + } + + // Calculate z-index behavior for ordinary page content. + if (canvasHost === document.body) { + var zIndexes = Array.from(document.querySelectorAll('body *')) + .filter(element => element !== testar_canvas) + .map(element => parseFloat(window.getComputedStyle(element).zIndex)) + .filter(zIndex => !isNaN(zIndex)); + + if (zIndexes.length > 0) { + var maxIndex = Math.max.apply(null, zIndexes); + var canvasIndex = parseFloat(window.getComputedStyle(testar_canvas).zIndex); + + if (isNaN(canvasIndex) || canvasIndex <= maxIndex) { + testar_canvas.style.zIndex = maxIndex + 1; + } + } } } diff --git a/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java b/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java index db6483cc5..43a43a994 100644 --- a/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java +++ b/webdriver/src/org/testar/monkey/alayer/webdriver/WdElement.java @@ -394,14 +394,10 @@ 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) { - return visibleAt(x, y); - } - private boolean isFullVisibleAtCanvasBrowser() { if (rect == null) return false; diff --git a/webdriver/src/org/testar/monkey/alayer/webdriver/WdHitTester.java b/webdriver/src/org/testar/monkey/alayer/webdriver/WdHitTester.java index 1382ad1e1..236cbbe71 100644 --- a/webdriver/src/org/testar/monkey/alayer/webdriver/WdHitTester.java +++ b/webdriver/src/org/testar/monkey/alayer/webdriver/WdHitTester.java @@ -55,15 +55,16 @@ public boolean apply(double x, double y) { } /** - * Runs the hit test action for a certain point on the element. + * Runs the geometric hit test for a certain point on the element. * * @param x The x-coordiante of the point. * @param y The y-coordinate of the point. - * @param obscuredByChildFeature The element is obscured by a child?? - * @return True if the element can be hit at the supplied point on the screen + * @param obscuredByChildFeature Shared API flag that is currently ignored by WebDriver. + * @return True if the point is inside the element's scroll-adjusted rectangle + * @implNote WebDriver does not perform child-obscuration checks in this overload. */ public boolean apply(double x, double y, boolean obscuredByChildFeature) { - return element.visibleAt(x, y, obscuredByChildFeature); + return element.visibleAt(x, y); } public String toString() {