diff --git a/Framework/Built_In_Automation/Mobile/CrossPlatform/Appium/BuiltInFunctions.py b/Framework/Built_In_Automation/Mobile/CrossPlatform/Appium/BuiltInFunctions.py index 9fae782b..788992ff 100755 --- a/Framework/Built_In_Automation/Mobile/CrossPlatform/Appium/BuiltInFunctions.py +++ b/Framework/Built_In_Automation/Mobile/CrossPlatform/Appium/BuiltInFunctions.py @@ -37,6 +37,12 @@ from selenium.webdriver.common.action_chains import ActionChains from appium.webdriver.common.appiumby import AppiumBy from Framework.Utilities import ConfigModule +from Framework.install_handler.android.emulator_manager import ( + EmulatorRuntimeError, + ensure_android_emulator, + list_running_emulators, + wait_for_android_runtime_stability, +) from Framework.Built_In_Automation.Shared_Resources import ( BuiltInFunctionSharedResources as Shared_Resources, ) @@ -212,143 +218,106 @@ def find_correct_device_on_first_run(serial_or_name, device_info): # Only used when launching an application, which creates the appium instance. sModuleInfo = inspect.currentframe().f_code.co_name + " : " + MODULE_NAME - global device_id, device_serial, appium_details + global device_id, device_serial, appium_details, appium_driver CommonUtil.ExecLog(sModuleInfo, "List of devices provided by server: %s" % str(device_info), 1) try: - # Get list of connected devices - devices = {} # Temporarily store connected device serial numberspick all_device_info = All_Device_Info.get_all_connected_device_info() + current_dependency = ( + Shared_Resources.Get_Shared_Variables("dependency") + if Shared_Resources.Test_Shared_Variables("dependency") + else {} + ) + expected_platform = str(current_dependency.get("Mobile", "")).lower().strip() + candidates = { + name: info + for name, info in all_device_info.items() + if not expected_platform + or str(info.get("type", "")).lower().strip() == expected_platform + } - # Ensure we have at least one device connected - if len(all_device_info) == 0: + if not candidates: CommonUtil.ExecLog( sModuleInfo, - "Could not detect any connected devices. Ensure at least one is attached via USB, and that it is authorized - Trusted / USB Debugging enabled", + f"Could not detect a connected {expected_platform or 'mobile'} device", 3, ) return "zeuz_failed" - imei = "" - device_name = "" - product_version = "" - serial = "" - did = "" - device_type = "" - - # Check if serial provided is a real serial number, name or rubish that should be ignored - serial_check = False - for device in all_device_info: # For each device serial number - if serial_or_name.lower() == device.lower(): - serial = all_device_info[device]["id"] # Save serial number - device_type = all_device_info[device]["type"].lower() # Save device type android/ios - imei = all_device_info[device]["imei"] - device_name = all_device_info[device]["model"] - product_version = all_device_info[device]["osver"] - did = device - serial_check = True # Flag as found - CommonUtil.ExecLog(sModuleInfo, "Found serial number in data set: %s" % serial, 0) - break + requested = str(serial_or_name or "").lower().strip() + ignored_identifiers = {"", "launch", "na", "n/a", "none", "default"} + selected_name = "" + + if requested not in ignored_identifiers: + for name, info in candidates.items(): + identifiers = { + name.lower(), + str(info.get("id", "")).lower(), + str(info.get("model", "")).lower(), + str(info.get("devname", "")).lower(), + str(info.get("avd_name", "")).lower(), + } + if requested in identifiers: + selected_name = name + break - # Check if user provided name - must be accompanied by device_info, sent by server - if serial_check == False: - for dname in device_info: - if serial_or_name.lower() == dname.lower(): - did = dname # Save device name - serial = device_info[did]["id"] # Save serial number - device_type = device_info[did]["type"].lower() # Save device type android/ios - imei = device_info[did]["imei"] - device_name = all_device_info[device]["model"] - product_version = all_device_info[device]["osver"] - serial_check = True + if not selected_name and isinstance(device_info, dict): + for deployed_name, deployed_info in device_info.items(): + if not isinstance(deployed_info, dict): + continue + deployed_serial = str(deployed_info.get("id", "")) + for name, info in candidates.items(): + if deployed_serial and deployed_serial == str(info.get("id", "")): + selected_name = name + break + if selected_name: CommonUtil.ExecLog( - sModuleInfo, "Found device name in data set: %s" % did, 0 + sModuleInfo, + f"Found device selected at deploy: {deployed_name}", + 1, ) break - ### If we were given either a serial/uuid or name in the data set, we should now have what we need to run ### - - # Not found, so now we have to look at the device_info dictionary from the server to determine the device to use - if serial_check == False: - # At least one device sent by server - if len(device_info) > 0: - did = "device 1" - serial = device_info[did]["id"] - imei = device_info[did]["imei"] - device_type = device_info[did]["type"].lower() - device_name = all_device_info[device]["model"] - product_version = all_device_info[device]["osver"] - CommonUtil.ExecLog(sModuleInfo, "Found a device selected at Deploy: %s" % did, 1) - - # Lastly, if nothing above is set, the user did not specify anything, and we have no information from the server. Pick a connected device, and fail if there are none - else: # No devices sent, none specified - for device in devices: - did = "default" - serial = device # Get Serial - device_type = devices[device] # Get type - CommonUtil.ExecLog(sModuleInfo, "No device information found. Picked one that is connected: %s" % serial, 2) - break # Only take the first device''' - - # At the end, we should have at least one device - if serial != "" and device_type != "" and did != "": - # Verify this device was not already selected and run previously - if did in appium_details: - CommonUtil.ExecLog( - sModuleInfo, - "The selected device was previously run. You cannot run it more than once in a session. Either your step data is calling the 'launch' action multiple times without specifying specific devices, or you did not call the 'teardown' action in a previous run.", - 2, - ) - CommonUtil.ExecLog( - sModuleInfo, - "If test case fails, please quit node, and make sure you have added tear down in your test.", - 2, - ) - return "passed" - - # Verify this device is actually connected - if not serial_in_devices(serial, all_device_info): - CommonUtil.ExecLog( - sModuleInfo, - "Although we have a selected device, it did not appear in the list of connected devices. Please ensure the device information aligns with what is connected: %s (%s)" - % (did, serial), - 3, - ) - return "zeuz_failed" + if not selected_name: + selected_name = next(iter(candidates)) - # Global variables for quick access to currently selected device - device_serial = serial - device_id = did - - # Global variable that holds data required by appium - appium_details[device_id] = {} - if "driver" not in appium_details[device_id]: - appium_details[device_id]["driver"] = None # Initialize appium driver object - if "server" not in appium_details[device_id]: - appium_details[device_id]["server"] = None - appium_details[device_id]["serial"] = serial - appium_details[device_id]["type"] = device_type - appium_details[device_id]["imei"] = imei - appium_details[device_id]["platform_version"] = product_version - appium_details[device_id]["device_name"] = device_name - - # Store in shared variable, so it doens't get forgotten - Shared_Resources.Set_Shared_Variables("device_serial", device_serial, protected=True) - Shared_Resources.Set_Shared_Variables("device_id", device_id, protected=True) # Save device id, because functions outside this file may require it + selected = candidates[selected_name] + serial = str(selected.get("id", "")) + device_type = str(selected.get("type", "")).lower().strip() + if not serial or not device_type: + CommonUtil.ExecLog(sModuleInfo, "Selected device information is incomplete", 3) + return "zeuz_failed" - CommonUtil.ExecLog( - sModuleInfo, - "Matched provided device identifier as %s (%s)" % (device_id, serial), - 1, - ) - return "passed" + device_serial = serial + device_id = selected_name + if device_id in appium_details: + appium_driver = appium_details[device_id].get("driver") else: - CommonUtil.ExecLog( - sModuleInfo, - "Although we found connected devices, provided information could not get all required information. Found devices: %s | Deployed device list: %s" - % (str(devices), str(device_info)), - 3, - ) - return "zeuz_failed" + appium_details[device_id] = { + "driver": None, + "server": None, + "serial": serial, + "type": device_type, + "imei": selected.get("imei", ""), + "platform_version": selected.get("osver", ""), + "device_name": selected.get("model", ""), + } + + Shared_Resources.Set_Shared_Variables( + "device_serial", device_serial, protected=True + ) + Shared_Resources.Set_Shared_Variables( + "device_id", device_id, protected=True + ) + if appium_driver is not None: + Shared_Resources.Set_Shared_Variables("appium_driver", appium_driver) + + CommonUtil.ExecLog( + sModuleInfo, + "Matched provided device identifier as %s (%s)" % (device_id, serial), + 1, + ) + return "passed" except: return CommonUtil.Exception_Handler( @@ -356,6 +325,85 @@ def find_correct_device_on_first_run(serial_or_name, device_info): ) +def _get_mobile_execution_config(): + if Shared_Resources.Test_Shared_Variables("mobile_execution"): + value = Shared_Resources.Get_Shared_Variables("mobile_execution") + if isinstance(value, dict): + return value + return {"target": "auto", "headless": False, "avd": "auto"} + + +def _prepare_android_device_for_launch( + requested_avd="auto", + requested_serial="", + headless=None, + boot_timeout=180, + stability_seconds=12, +): + """Start or stabilize an emulator when the selected Android target needs one.""" + execution = _get_mobile_execution_config() + target_type = str(execution.get("target", "auto")).lower().strip() + if headless is None: + headless = bool(execution.get("headless", False)) + if not requested_avd or str(requested_avd).lower().strip() == "auto": + requested_avd = execution.get("avd", "auto") + + connected = All_Device_Info.get_all_connected_device_info() + android_serials = { + str(info.get("id", "")) + for info in connected.values() + if str(info.get("type", "")).lower().strip() == "android" + } + running_emulators = list_running_emulators() + running_serials = {item.serial for item in running_emulators} + physical_serials = android_serials - running_serials + + preferred_serial = str(requested_serial or "").strip() + ignored_serials = {"", "launch", "auto", "default", "none", "na", "n/a"} + if preferred_serial.lower() in ignored_serials: + selected_devices = ( + Shared_Resources.Get_Shared_Variables("device_info") + if Shared_Resources.Test_Shared_Variables("device_info") + else {} + ) + if isinstance(selected_devices, dict): + preferred_serial = next( + ( + str(info.get("id", "")) + for info in selected_devices.values() + if isinstance(info, dict) + and str(info.get("id", "")) in running_serials + ), + preferred_serial, + ) + + must_use_emulator = target_type == "emulator" or bool(headless) + selected_an_emulator = preferred_serial in running_serials + should_prepare_emulator = must_use_emulator or selected_an_emulator or ( + bool(running_emulators) and not physical_serials + ) + if not should_prepare_emulator and android_serials: + return "" + + target = ensure_android_emulator( + requested_avd=str(requested_avd or "auto"), + requested_serial=preferred_serial, + headless=bool(headless), + boot_timeout=int(boot_timeout), + stability_seconds=int(stability_seconds), + ) + refreshed_device_info = All_Device_Info.get_all_connected_device_info() + Shared_Resources.Set_Shared_Variables( + "device_info", refreshed_device_info, protected=True + ) + CommonUtil.ExecLog( + "prepare_android_device_for_launch", + f"Android emulator '{target.avd_name}' is ready on {target.serial}", + 1, + ) + return target.serial + + @logger def unlock_android_device(data_set): """ Unlocks an android device with adb commands""" @@ -562,6 +610,7 @@ def launch_application(data_set): # Recall appium details if Shared_Resources.Test_Shared_Variables("device_info"): # Check if device_info is already set in shared variables device_info = Shared_Resources.Get_Shared_Variables("device_info") # Retrieve device_info + device_order = [] if Shared_Resources.Test_Shared_Variables("device_order"): device_order = Shared_Resources.Get_Shared_Variables("device_order") @@ -600,6 +649,10 @@ def launch_application(data_set): macos = "" no_reset = False work_profile = False + requested_avd = "auto" + emulator_headless = None + emulator_boot_timeout = 180 + system_ui_stability_seconds = 12 for left, mid, right in data_set: left = left.strip().lower() @@ -619,6 +672,23 @@ def launch_application(data_set): no_reset = True else: no_reset = False + elif "optional parameter" in mid and "=" in right: + key, value = map(lambda item: item.strip(), right.split("=", 1)) + normalized_key = key.lower().replace("_", " ").replace("-", " ") + normalized_key = " ".join(normalized_key.split()) + if normalized_key in {"avd", "avd name", "emulator avd"}: + requested_avd = value + elif normalized_key in {"headless", "emulator headless"}: + emulator_headless = value.lower() in ( + "yes", "true", "1", "on", "enable", "enabled" + ) + elif normalized_key in {"emulator boot timeout", "avd boot timeout"}: + emulator_boot_timeout = max(30, int(value)) + elif normalized_key in { + "system ui stability seconds", + "emulator stability seconds", + }: + system_ui_stability_seconds = max(4, int(value)) elif mid == "action": serial = right.lower().strip() @@ -626,6 +696,40 @@ def launch_application(data_set): desiredcaps = dict() desiredcaps['unicodeKeyboard'] = False desiredcaps['resetKeyboard'] = False + + current_dependency = ( + Shared_Resources.Get_Shared_Variables("dependency") + if Shared_Resources.Test_Shared_Variables("dependency") + else {} + ) + is_local_android = ( + not ios + and not macos + and ( + str(current_dependency.get("Mobile", "")).lower().strip() + == "android" + or bool(package_name) + ) + ) + if is_local_android: + try: + prepared_serial = _prepare_android_device_for_launch( + requested_avd=requested_avd, + requested_serial=serial, + headless=emulator_headless, + boot_timeout=emulator_boot_timeout, + stability_seconds=system_ui_stability_seconds, + ) + if prepared_serial: + serial = prepared_serial + if Shared_Resources.Test_Shared_Variables("device_info"): + device_info = Shared_Resources.Get_Shared_Variables( + "device_info" + ) + except EmulatorRuntimeError as exc: + CommonUtil.ExecLog(sModuleInfo, str(exc), 3) + return "zeuz_failed" + # Set the global variable for the preferred connected device if find_correct_device_on_first_run(serial, device_info) in failed_tag_list and macos == "": return "zeuz_failed" @@ -639,8 +743,24 @@ def launch_application(data_set): left, mid = left.strip().lower(), mid.strip().lower() if "optional parameter" in mid and "=" in right: # key, value - k, v = map(lambda x: x.strip(), right.split("=")) - if left in (device_type, "multi"): + k, v = map(lambda x: x.strip(), right.split("=", 1)) + normalized_key = k.lower().replace("_", " ").replace("-", " ") + normalized_key = " ".join(normalized_key.split()) + emulator_runtime_keys = { + "avd", + "avd name", + "emulator avd", + "headless", + "emulator headless", + "emulator boot timeout", + "avd boot timeout", + "system ui stability seconds", + "emulator stability seconds", + } + if ( + left in (device_type, "multi") + and normalized_key not in emulator_runtime_keys + ): desiredcaps[k] = v # Send wake up command to avoid issues with devices ignoring appium when they are in lower power mode (android 6.0+), and unlock if passworded @@ -712,6 +832,34 @@ def launch_application(data_set): if launch_app and macos == "": # if ios simulator then no need to launch app again appium_driver.activate_app(package_name) # Launch program configured in the Appium capabilities + if ( + appium_details[device_id]["type"] == "android" + and any( + emulator.serial == device_serial + for emulator in list_running_emulators() + ) + ): + try: + def select_system_ui_wait(): + appium_driver.find_element( + AppiumBy.ID, + "android:id/aerr_wait", + ).click() + CommonUtil.ExecLog( + sModuleInfo, + "Selected Wait on the System UI not responding dialog", + 2, + ) + return True + + wait_for_android_runtime_stability( + device_serial, + stability_seconds=system_ui_stability_seconds, + anr_wait_action=select_system_ui_wait, + ) + except EmulatorRuntimeError as exc: + CommonUtil.ExecLog(sModuleInfo, str(exc), 3) + return "zeuz_failed" CommonUtil.ExecLog(sModuleInfo, "Launched the application successfully.", 1) return "passed" except Exception: @@ -5890,4 +6038,3 @@ def pan_action(data_set): return "passed" except: return CommonUtil.Exception_Handler(sys.exc_info(), None, "Unable to parse data for Pan. Please write data in correct format") - diff --git a/Framework/MainDriverApi.py b/Framework/MainDriverApi.py index 4c0cf84f..502aed5d 100644 --- a/Framework/MainDriverApi.py +++ b/Framework/MainDriverApi.py @@ -2018,6 +2018,14 @@ async def main(device_dict, all_run_id_info): final_dependency = run_id_info["dependency_list"] shared.Set_Shared_Variables("dependency", final_dependency, protected=True) + shared.Set_Shared_Variables( + "mobile_execution", + run_id_info.get( + "mobile_execution", + {"target": "auto", "headless": False, "avd": "auto"}, + ), + protected=True, + ) final_run_params_from_server = run_id_info["run_time"] final_run_params = {} diff --git a/Framework/Utilities/All_Device_Info.py b/Framework/Utilities/All_Device_Info.py index 58dcfdbd..2708c684 100755 --- a/Framework/Utilities/All_Device_Info.py +++ b/Framework/Utilities/All_Device_Info.py @@ -3,70 +3,100 @@ # -*- coding: cp1252 -*- -import sys, subprocess, time -from Framework.Utilities import CommonUtil import json +import shutil +import subprocess +import sys + +from Framework.install_handler.android.android_sdk import get_adb_path # These create the device name we give to each device device_name = "device " device_cnt = 1 +def _get_adb_executable(): + """Prefer the ZeuZ-managed adb and fall back to a native adb on PATH.""" + managed_adb = get_adb_path() + if managed_adb.is_file(): + return str(managed_adb) + return shutil.which("adb") + + +def _adb_output(adb_executable, *args, timeout=30): + return subprocess.check_output( + [adb_executable, *args], + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + + def get_all_connected_android_info(): - """ For all connected Android devices, get specified information and return in a dictionary with the serial number as the top level key """ + """For all connected Android devices, get specified information and return in a dictionary with the serial number as the top level key""" global device_cnt try: android_list = [] device_list = {} - # adb commands - android_cmd_os_version = "shell getprop ro.build.version.release" - android_cmd_model = "shell getprop ro.product.model" - android_cmd_name = "shell getprop ro.product.name" - android_cmd_mfg = "shell getprop ro.product.manufacturer" - android_cmd_imei = "shell dumpsys iphonesubinfo" - android_cmd_alt_imei = "shell service call iphonesubinfo 1" # Usually needed for CDMA phones + adb_executable = _get_adb_executable() + if not adb_executable: + return {} # Get list of devices - # One report of this blocking the next line, so disabled: result = subprocess.check_output('adb kill-server', shell=True) # Stop adb server, to ensure this works properly - subprocess.check_output("adb start-server", shell=True, encoding="utf-8") - result = subprocess.check_output("adb devices", shell=True, encoding="utf-8") - result = result.replace("List of devices attached", "") - result = result.replace("\r", "") - result = result.replace("\t", " ") - result = result.split("\n") - for device in result: - if "device" in device: - android_list.append(str(device.split(" ")[0]).strip()) + _adb_output(adb_executable, "start-server") + result = _adb_output(adb_executable, "devices") + for line in result.replace("\r", "").splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[1] == "device": + android_list.append(fields[0]) if len(android_list) == 0: return False # Get device information for serial in android_list: # Execute commands - os_version = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_os_version), - shell=True, - encoding="utf-8", + os_version = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "getprop", + "ro.build.version.release", ) - model = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_model), - shell=True, - encoding="utf-8", + model = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "getprop", + "ro.product.model", ) - name = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_name), - shell=True, - encoding="utf-8", + name = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "getprop", + "ro.product.name", ) - mfg = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_mfg), shell=True, encoding="utf-8" + mfg = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "getprop", + "ro.product.manufacturer", ) - imei = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_imei), - shell=True, - encoding="utf-8", + imei = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "dumpsys", + "iphonesubinfo", ) # Cleanup of results @@ -81,10 +111,15 @@ def get_all_connected_android_info(): imei.split(" ")[-1] ).strip() # Last word on this line is IMEI else: # Try alternative method to get imei - imei = subprocess.check_output( - "adb -s %s %s" % (serial, android_cmd_alt_imei), - shell=True, - encoding="utf-8", + imei = _adb_output( + adb_executable, + "-s", + serial, + "shell", + "service", + "call", + "iphonesubinfo", + "1", ) imei = imei.split(" ") final = "" @@ -120,7 +155,7 @@ def get_all_connected_android_info(): def get_all_connected_ios_info(): - """ For all connected IOS devices, get specified information and return in a dictionary with the UUID as the top level key """ + """For all connected IOS devices, get specified information and return in a dictionary with the UUID as the top level key""" global device_cnt try: @@ -184,10 +219,9 @@ def get_all_connected_ios_info(): def get_all_booted_ios_simulator_info(): - """ For all booted simulator IOS devices, get specified information and return in a dictionary with the UUID as the top level key """ + """For all booted simulator IOS devices, get specified information and return in a dictionary with the UUID as the top level key""" global device_cnt try: - device_list = {} all_ios_simulators = subprocess.check_output( "xcrun simctl list --json", shell=True, encoding="utf-8" @@ -230,7 +264,7 @@ def get_all_booted_ios_simulator_info(): def get_all_connected_device_info(): - """ For all connected IOS and Android devices, get specified information and return in a dictionary with the serial number/UUID as the top level key """ + """For all connected IOS and Android devices, get specified information and return in a dictionary with the serial number/UUID as the top level key""" try: device_list = {} diff --git a/Framework/Utilities/CommonUtil.py b/Framework/Utilities/CommonUtil.py index d53bb852..2c3c610b 100644 --- a/Framework/Utilities/CommonUtil.py +++ b/Framework/Utilities/CommonUtil.py @@ -885,12 +885,18 @@ async def TakeScreenShot(function_name, local_run=False, pre_action=False): Method = screen_capture_type Driver = screen_capture_driver - # The driver for web/mobile may not exist yet when the BEFORE frame is requested - # (e.g. Go_To_Link launches the browser, so no selenium_driver exists until the - # action runs). A missing driver for a pre-action capture is expected, not an - # error, so skip it silently instead of announcing the capture and then logging a - # level-3 error from Thread_ScreenShot. - if pre_action and Method in ("mobile", "web") and Driver is None: + # The driver for web/mobile may not exist around a launch action. Before the + # action this is expected; after a failed launch, the action already logged the + # useful root cause. Do not add a second level-3 screenshot error for a driver + # that was never created. + if Method in ("mobile", "web") and Driver is None: + if not pre_action: + ExecLog( + sModuleInfo, + "Skipping screenshot because the %s driver is not available" + % Method, + 0, + ) return # Decide if screenshot should be captured diff --git a/Framework/deploy_handler/adapter.py b/Framework/deploy_handler/adapter.py index 188fefa0..4b3636d8 100644 --- a/Framework/deploy_handler/adapter.py +++ b/Framework/deploy_handler/adapter.py @@ -9,6 +9,35 @@ # from pb.v1.deploy_response_message_pb2 import DeployResponse +def normalize_mobile_execution(value: str) -> tuple[str, Dict[str, Any]]: + """Separate the mobile platform from its local execution target.""" + raw_value = (value or "").strip() + normalized = raw_value.lower().replace("_", " ").replace("-", " ") + normalized = " ".join(normalized.split()) + + if normalized in {"android headless", "android emulator headless"}: + return "Android", { + "target": "emulator", + "headless": True, + "avd": "auto", + } + if normalized == "android": + return "Android", { + "target": "auto", + "headless": False, + "avd": "auto", + } + if normalized == "ios simulator": + return "iOS", { + "target": "simulator", + "headless": False, + } + return raw_value, { + "target": "device", + "headless": False, + } + + def read_actions(actions_pb) -> List[Dict]: actions = [] for action in actions_pb: @@ -96,6 +125,9 @@ def adapt(message: str, node_id: str) -> List[Dict]: """ r = json.loads(message) + mobile_platform, mobile_execution = normalize_mobile_execution( + r["deployInfo"]["dependency"]["mobile"] + ) # TODO: Check r.server_version and create different adapeter classes for new # schemaa changes. @@ -113,9 +145,11 @@ def adapt(message: str, node_id: str) -> List[Dict]: "dependency_list": { "Browser": r["deployInfo"]["dependency"]["browser"], - "Mobile": r["deployInfo"]["dependency"]["mobile"], + "Mobile": mobile_platform, }, + "mobile_execution": mobile_execution, + "device_info": None, "run_time": {}, diff --git a/Framework/install_handler/android/appium.py b/Framework/install_handler/android/appium.py index 15f395f0..4af73a4b 100644 --- a/Framework/install_handler/android/appium.py +++ b/Framework/install_handler/android/appium.py @@ -16,7 +16,7 @@ async def check_status() -> bool: # Use the check function from nodejs_appium_installer node_installed, appium_installed, missing_drivers = check_installations() - if appium_installed: + if appium_installed and not missing_drivers: # Get the installation location appium_path = get_appium_path() node_dir = get_node_dir() @@ -51,6 +51,22 @@ async def check_status() -> bool: } }) return True + elif appium_installed: + missing_text = ", ".join(missing_drivers) + logger.warning( + "[installer][android-appium] Missing required drivers: %s", + missing_text, + ) + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": f"Appium is installed, but required drivers are missing: {missing_text}", + } + }) + return False else: logger.info("[installer][android-appium] Not installed") await send_response({ @@ -71,7 +87,7 @@ async def check_status() -> bool: "category": "Android", "name": "Appium", "status": "not installed", - "comment": "Run the ZeuZ Node, it will automatically install Appium", + "comment": f"Unable to verify Appium: {str(e)[:160]}", } }) return False @@ -230,4 +246,4 @@ async def install() -> bool: "comment": f"Installation error: {str(e)[:100]}", } }) - return False \ No newline at end of file + return False diff --git a/Framework/install_handler/android/emulator.py b/Framework/install_handler/android/emulator.py index c1a834a5..79e118fa 100644 --- a/Framework/install_handler/android/emulator.py +++ b/Framework/install_handler/android/emulator.py @@ -8,11 +8,10 @@ import re import random import traceback -import tempfile from pathlib import Path -from settings import ZEUZ_NODE_DOWNLOADS_DIR from Framework.install_handler.utils import send_response, debug from Framework.install_handler.android.android_sdk import _get_sdk_root +from Framework.install_handler.android.emulator_manager import ensure_android_emulator from Framework.install_handler.install_log_config import get_logger logger = get_logger() @@ -286,91 +285,36 @@ async def get_available_avds() -> list[dict]: async def launch_avd(avd_name: str) -> bool: """ - Launch AVD using emulator command determined by OS. - Non-blocking - the emulator starts in the background. - Sends response to server on success or failure. + Launch an AVD and wait until it is safe for automation. """ try: - sdk_root = _get_sdk_root() - emulator_path = get_emulator_command() - env = _build_android_process_env(sdk_root) - sanitized_name = re.sub(r"[^a-zA-Z0-9._-]", "_", avd_name) or "avd" - log_dir = Path(tempfile.gettempdir()) / "zeuz" / "android_emulator_logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_path = log_dir / f"{sanitized_name}.log" - - def _spawn_emulator(cmd: list[str]) -> subprocess.Popen: - with open(log_path, "w", encoding="utf-8") as log_file: - return subprocess.Popen( - cmd, - stdout=log_file, - stderr=subprocess.STDOUT, - start_new_session=True, # Detach from parent process - env=env - ) - - # Try launch with explicit sdk-root first. - process = _spawn_emulator([emulator_path, "-avd", avd_name, "-sdk-root", str(sdk_root)]) - await asyncio.sleep(3) - returncode = process.poll() - - # macOS compatibility: retry without -sdk-root if first launch exits immediately. - if returncode is not None and _is_darwin(): - if debug: - logger.debug("[installer][emulator] Emulator exited quickly with -sdk-root on macOS. Retrying without -sdk-root.") - process = _spawn_emulator([emulator_path, "-avd", avd_name]) - await asyncio.sleep(3) - returncode = process.poll() - - if returncode is not None: - launch_hint = _read_file_tail(log_path) - error_msg = f"Emulator process for {avd_name} exited immediately (code {returncode})." - if launch_hint: - error_msg += f" Output hint: {launch_hint[:500]}" - logger.error("[installer][emulator] %s", error_msg) - await send_response({ - "action": "status", - "data": { - "category": "AndroidEmulator", - "name": avd_name, - "status": "not installed", - "comment": error_msg, - } - }) - return False - - logger.info("[installer][emulator] Launching AVD: %s... (PID: %s)", avd_name, process.pid) - - # Send success response to server await send_response({ "action": "status", "data": { "category": "AndroidEmulator", "name": avd_name, - "status": "installed", - "comment": f"Emulator {avd_name} is launching (PID: {process.pid})", + "status": "installing", + "comment": f"Starting {avd_name} and waiting for Android to become stable...", } }) - return True - - except FileNotFoundError: - error_msg = f"Emulator executable not found" - logger.error("[installer][emulator] %s", error_msg) + target = await asyncio.to_thread( + ensure_android_emulator, + requested_avd=avd_name, + headless=False, + ) await send_response({ "action": "status", "data": { "category": "AndroidEmulator", "name": avd_name, - "status": "not installed", - "comment": f"Failed to launch {avd_name}: {error_msg}", + "status": "installed", + "comment": f"Emulator {avd_name} is ready on {target.serial}", } }) - return False + return True except Exception as e: error_msg = f"Failed to launch AVD {avd_name}: {e}" - logger.error("[installer][emulator] %s", error_msg) - traceback.print_exc() await send_response({ "action": "status", "data": { @@ -1928,4 +1872,3 @@ async def create_avd_from_system_image(device_param: str) -> bool: ######################################## - diff --git a/Framework/install_handler/android/emulator_manager.py b/Framework/install_handler/android/emulator_manager.py new file mode 100644 index 00000000..c0b05be9 --- /dev/null +++ b/Framework/install_handler/android/emulator_manager.py @@ -0,0 +1,750 @@ +"""Runtime lifecycle helpers for Android Virtual Devices. + +The installer UI and Appium actions both use this module so an emulator is not +considered usable merely because its host process was spawned. A target is +returned only after Android, Package Manager, and System UI have been stable +for a short period. +""" + +from __future__ import annotations + +import atexit +import os +import platform +import re +import subprocess +import tempfile +import threading +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from Framework.install_handler.android.android_sdk import _get_sdk_root, get_adb_path +from Framework.install_handler.install_log_config import get_logger + + +logger = get_logger() + +DEFAULT_BOOT_TIMEOUT = 180 +DEFAULT_STABILITY_SECONDS = 12 +_POLL_INTERVAL_SECONDS = 2 +_AUTO_AVD_VALUES = {"", "auto", "default", "none"} +_OWNED_PROCESSES: dict[str, subprocess.Popen] = {} +_REGISTRY_LOCK = threading.RLock() +_LIFECYCLE_LOCK = threading.RLock() + + +class EmulatorRuntimeError(RuntimeError): + """Raised when an AVD cannot be prepared for automation.""" + + +class SystemUiAnrError(EmulatorRuntimeError): + """Raised when the emulator remains blocked by a System UI ANR.""" + + +@dataclass(frozen=True) +class RunningEmulator: + serial: str + avd_name: str + + +@dataclass(frozen=True) +class EmulatorTarget: + serial: str + avd_name: str + launched_by_zeuz: bool + headless: bool + + +def _android_process_env() -> dict[str, str]: + sdk_root = _get_sdk_root() + env = os.environ.copy() + env["ANDROID_HOME"] = str(sdk_root) + env["ANDROID_SDK_ROOT"] = str(sdk_root) + sdk_paths = [ + str(sdk_root / "platform-tools"), + str(sdk_root / "emulator"), + str(sdk_root / "cmdline-tools" / "latest" / "bin"), + ] + current_path = env.get("PATH", "") + env["PATH"] = os.pathsep.join([*sdk_paths, current_path]) + return env + + +def get_emulator_path() -> Path: + executable = "emulator.exe" if platform.system() == "Windows" else "emulator" + return _get_sdk_root() / "emulator" / executable + + +def _run( + command: list[str], + *, + timeout: int = 20, + text: bool = True, +) -> subprocess.CompletedProcess: + try: + return subprocess.run( + command, + capture_output=True, + text=text, + timeout=timeout, + env=_android_process_env(), + check=False, + ) + except subprocess.TimeoutExpired as exc: + empty_output = "" if text else b"" + return subprocess.CompletedProcess( + command, + 124, + stdout=exc.stdout or empty_output, + stderr=exc.stderr or empty_output, + ) + + +def _adb_command(*args: str) -> list[str]: + return [str(get_adb_path()), *args] + + +def _parse_adb_devices(output: str) -> list[str]: + serials = [] + for line in output.replace("\r", "").splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[1] == "device": + serials.append(fields[0]) + return serials + + +def list_online_android_serials() -> list[str]: + try: + result = _run(_adb_command("devices"), timeout=20) + except (FileNotFoundError, subprocess.SubprocessError): + return [] + if result.returncode != 0: + return [] + return _parse_adb_devices(result.stdout) + + +def _is_emulator(serial: str) -> bool: + if serial.startswith("emulator-"): + return True + result = _run( + _adb_command("-s", serial, "shell", "getprop", "ro.kernel.qemu"), + timeout=10, + ) + return result.returncode == 0 and result.stdout.strip() == "1" + + +def _get_running_avd_name(serial: str) -> str: + result = _run(_adb_command("-s", serial, "emu", "avd", "name"), timeout=10) + if result.returncode != 0: + return "" + for line in result.stdout.replace("\r", "").splitlines(): + value = line.strip() + if value and value.upper() != "OK": + return value + return "" + + +def list_running_emulators() -> list[RunningEmulator]: + emulators = [] + for serial in list_online_android_serials(): + if not _is_emulator(serial): + continue + emulators.append( + RunningEmulator(serial=serial, avd_name=_get_running_avd_name(serial)) + ) + return emulators + + +def list_avd_names() -> list[str]: + emulator_path = get_emulator_path() + if not emulator_path.is_file(): + managed_adb = get_adb_path() + missing = ["Android Emulator"] + if not managed_adb.is_file(): + missing.insert(0, "ADB") + raise EmulatorRuntimeError( + f"Android tooling is not installed for this {platform.system()} ZeuZ Node. " + f"Missing: {', '.join(missing)}. Expected SDK location: " + f"{_get_sdk_root()}. Install Android SDK and an AVD from Connected " + "ZeuZ Nodes, then run the test again." + ) + result = _run([str(emulator_path), "-list-avds"], timeout=30) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise EmulatorRuntimeError(f"Unable to list Android AVDs: {detail}") + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def resolve_avd_name(requested_avd: str | None = None) -> str: + requested = (requested_avd or "").strip() + available = list_avd_names() + if requested.lower() not in _AUTO_AVD_VALUES: + if requested not in available: + raise EmulatorRuntimeError( + f"Requested AVD '{requested}' is not installed. Available AVDs: " + f"{', '.join(available) or 'none'}" + ) + return requested + + configured_default = os.environ.get("ZEUZ_ANDROID_DEFAULT_AVD", "").strip() + if configured_default: + if configured_default not in available: + raise EmulatorRuntimeError( + "ZEUZ_ANDROID_DEFAULT_AVD points to an AVD that is not installed: " + f"{configured_default}" + ) + return configured_default + + if not available: + raise EmulatorRuntimeError( + "No Android AVD is installed. Install one from Connected ZeuZ Nodes first." + ) + if len(available) > 1: + logger.warning( + "[emulator-runtime] Multiple AVDs are installed; using %s. " + "Set ZEUZ_ANDROID_DEFAULT_AVD or provide avd= to choose another.", + available[0], + ) + return available[0] + + +def _build_emulator_command( + avd_name: str, + *, + headless: bool, + cold_boot: bool, +) -> list[str]: + command = [ + str(get_emulator_path()), + "-avd", + avd_name, + "-no-audio", + "-no-boot-anim", + ] + if headless: + command.append("-no-window") + if cold_boot: + # Skip loading a potentially unhealthy Quick Boot snapshot without + # wiping the AVD's user data. + command.append("-no-snapshot-load") + return command + + +def _log_directory() -> Path: + path = Path(tempfile.gettempdir()) / "zeuz" / "android_emulator_logs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _safe_avd_filename(avd_name: str) -> str: + return re.sub(r"[^a-zA-Z0-9._-]", "_", avd_name) or "avd" + + +def _spawn_emulator( + avd_name: str, + *, + headless: bool, + cold_boot: bool, +) -> subprocess.Popen: + log_path = _log_directory() / f"{_safe_avd_filename(avd_name)}.log" + command = _build_emulator_command( + avd_name, + headless=headless, + cold_boot=cold_boot, + ) + popen_options = { + "stdout": None, + "stderr": subprocess.STDOUT, + "env": _android_process_env(), + } + if os.name == "nt": + popen_options["creationflags"] = getattr( + subprocess, "CREATE_NEW_PROCESS_GROUP", 0 + ) | getattr(subprocess, "DETACHED_PROCESS", 0) + else: + popen_options["start_new_session"] = True + + with open(log_path, "w", encoding="utf-8") as log_file: + popen_options["stdout"] = log_file + process = subprocess.Popen(command, **popen_options) + + with _REGISTRY_LOCK: + _OWNED_PROCESSES[avd_name] = process + logger.info( + "[emulator-runtime] Started AVD %s (PID %s, headless=%s, cold_boot=%s)", + avd_name, + process.pid, + headless, + cold_boot, + ) + return process + + +def _read_log_tail(avd_name: str, max_lines: int = 25) -> str: + log_path = _log_directory() / f"{_safe_avd_filename(avd_name)}.log" + try: + lines = log_path.read_text(encoding="utf-8", errors="ignore").splitlines() + return " | ".join(line.strip() for line in lines[-max_lines:] if line.strip()) + except Exception: + return "" + + +def _wait_for_serial( + avd_name: str, + process: subprocess.Popen, + baseline_serials: set[str], + deadline: float, +) -> str: + fallback_serial = "" + while time.monotonic() < deadline: + if process.poll() is not None: + hint = _read_log_tail(avd_name) + raise EmulatorRuntimeError( + f"AVD '{avd_name}' exited during startup with code {process.returncode}. " + f"{hint}" + ) + for emulator in list_running_emulators(): + if emulator.avd_name == avd_name: + return emulator.serial + if emulator.serial not in baseline_serials: + fallback_serial = emulator.serial + if fallback_serial: + return fallback_serial + time.sleep(_POLL_INTERVAL_SECONDS) + raise EmulatorRuntimeError( + f"AVD '{avd_name}' did not connect to ADB before the startup timeout." + ) + + +def _getprop(serial: str, prop: str) -> str: + result = _run( + _adb_command("-s", serial, "shell", "getprop", prop), + timeout=10, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def _package_manager_is_ready(serial: str) -> bool: + result = _run( + _adb_command( + "-s", serial, "shell", "cmd", "package", "list", "packages", "android" + ), + timeout=20, + ) + return result.returncode == 0 and "package:android" in result.stdout + + +def _wait_for_android_boot(serial: str, deadline: float) -> None: + while time.monotonic() < deadline: + if serial not in list_online_android_serials(): + time.sleep(_POLL_INTERVAL_SECONDS) + continue + boot_completed = _getprop(serial, "sys.boot_completed") == "1" + boot_animation = _getprop(serial, "init.svc.bootanim") + if ( + boot_completed + and boot_animation in {"", "stopped"} + and _package_manager_is_ready(serial) + ): + return + time.sleep(_POLL_INTERVAL_SECONDS) + raise EmulatorRuntimeError( + f"Android on {serial} did not finish booting before the startup timeout." + ) + + +def _focused_window(serial: str) -> str: + result = _run( + _adb_command("-s", serial, "shell", "dumpsys", "window", "windows"), + timeout=20, + ) + if result.returncode != 0: + return "" + focus_lines = [ + line + for line in result.stdout.splitlines() + if "mCurrentFocus" in line or "mFocusedApp" in line + ] + return "\n".join(focus_lines) + + +def _is_system_ui_anr_text(value: str) -> bool: + lowered = value.lower().replace("’", "'") + mentions_system_ui = "com.android.systemui" in lowered or "system ui" in lowered + mentions_anr = ( + "isn't responding" in lowered + or "is not responding" in lowered + or "application error" in lowered + or "application not responding" in lowered + ) + return mentions_system_ui and mentions_anr + + +def _parse_bounds(value: str) -> tuple[int, int] | None: + match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", value or "") + if not match: + return None + left, top, right, bottom = map(int, match.groups()) + return ((left + right) // 2, (top + bottom) // 2) + + +def _dump_ui(serial: str) -> str: + remote_path = "/data/local/tmp/zeuz-window.xml" + dump = _run( + _adb_command( + "-s", serial, "shell", "uiautomator", "dump", "--compressed", remote_path + ), + timeout=25, + ) + if dump.returncode != 0: + return "" + result = _run( + _adb_command("-s", serial, "exec-out", "cat", remote_path), + timeout=15, + ) + return result.stdout if result.returncode == 0 else "" + + +def _dismiss_system_ui_anr(serial: str) -> bool: + """Click only Android's ANR "Wait" button; never close the System UI app.""" + for _ in range(3): + xml = _dump_ui(serial) + if not xml: + time.sleep(_POLL_INTERVAL_SECONDS) + continue + try: + root = ET.fromstring(xml) + except ET.ParseError: + time.sleep(_POLL_INTERVAL_SECONDS) + continue + + all_text = " ".join( + " ".join( + filter( + None, + (node.attrib.get("text", ""), node.attrib.get("content-desc", "")), + ) + ) + for node in root.iter() + ) + if not _is_system_ui_anr_text(all_text): + return True + + for node in root.iter(): + resource_id = node.attrib.get("resource-id", "").lower() + text = node.attrib.get("text", "").strip().lower() + if resource_id.endswith("aerr_wait") or text == "wait": + center = _parse_bounds(node.attrib.get("bounds", "")) + if not center: + continue + result = _run( + _adb_command( + "-s", + serial, + "shell", + "input", + "touchscreen", + "tap", + str(center[0]), + str(center[1]), + ), + timeout=10, + ) + if result.returncode == 0: + logger.warning( + "[emulator-runtime] Dismissed System UI ANR with the safe Wait action on %s", + serial, + ) + time.sleep(4) + return True + time.sleep(_POLL_INTERVAL_SECONDS) + return False + + +def _wait_for_stable_system_ui( + serial: str, + *, + deadline: float, + stability_seconds: int, + anr_wait_action: Callable[[], bool] | None = None, +) -> None: + stable_since: float | None = None + saw_system_ui_anr = False + + while time.monotonic() < deadline: + pid = _run( + _adb_command("-s", serial, "shell", "pidof", "com.android.systemui"), + timeout=10, + ) + focus = _focused_window(serial) + if _is_system_ui_anr_text(focus): + if not saw_system_ui_anr: + _capture_failure_screenshot(serial, f"system-ui-anr-{serial}") + saw_system_ui_anr = True + stable_since = None + try: + if anr_wait_action is not None: + anr_wait_action() + else: + _dismiss_system_ui_anr(serial) + except Exception: + logger.warning( + "[emulator-runtime] Could not select the System UI ANR Wait action on %s", + serial, + ) + time.sleep(_POLL_INTERVAL_SECONDS) + continue + + if pid.returncode == 0 and pid.stdout.strip(): + if stable_since is None: + stable_since = time.monotonic() + if time.monotonic() - stable_since >= stability_seconds: + return + else: + stable_since = None + time.sleep(_POLL_INTERVAL_SECONDS) + + if saw_system_ui_anr: + raise SystemUiAnrError( + f"System UI on {serial} remained unresponsive after Android booted." + ) + raise EmulatorRuntimeError( + f"System UI on {serial} did not remain stable for {stability_seconds} seconds." + ) + + +def _capture_failure_screenshot(serial: str, avd_name: str) -> Path | None: + try: + result = _run( + _adb_command("-s", serial, "exec-out", "screencap", "-p"), + timeout=20, + text=False, + ) + if result.returncode != 0 or not result.stdout: + return None + timestamp = time.strftime("%Y%m%d-%H%M%S") + path = _log_directory() / f"{_safe_avd_filename(avd_name)}-{timestamp}.png" + path.write_bytes(result.stdout) + logger.warning("[emulator-runtime] Saved readiness screenshot to %s", path) + return path + except Exception: + return None + + +def _prepare_running_target( + emulator: RunningEmulator, + *, + headless: bool, + boot_timeout: int, + stability_seconds: int, +) -> EmulatorTarget: + boot_deadline = time.monotonic() + boot_timeout + try: + _wait_for_android_boot(emulator.serial, boot_deadline) + _wait_for_stable_system_ui( + emulator.serial, + deadline=time.monotonic() + max(60, stability_seconds * 3), + stability_seconds=stability_seconds, + ) + except EmulatorRuntimeError: + _capture_failure_screenshot(emulator.serial, emulator.avd_name or "emulator") + raise + with _REGISTRY_LOCK: + process = _OWNED_PROCESSES.get(emulator.avd_name) + owned = process is not None and process.poll() is None + return EmulatorTarget( + serial=emulator.serial, + avd_name=emulator.avd_name, + launched_by_zeuz=owned, + headless=headless, + ) + + +def _stop_owned_emulator(avd_name: str, serial: str = "") -> None: + if serial: + try: + _run(_adb_command("-s", serial, "emu", "kill"), timeout=15) + except Exception: + pass + with _REGISTRY_LOCK: + process = _OWNED_PROCESSES.pop(avd_name, None) + if process is not None and process.poll() is None: + try: + process.terminate() + process.wait(timeout=15) + except Exception: + try: + process.kill() + except Exception: + pass + + +def wait_for_android_runtime_stability( + serial: str, + *, + stability_seconds: int = DEFAULT_STABILITY_SECONDS, + timeout: int = 60, + anr_wait_action: Callable[[], bool] | None = None, +) -> None: + """Wait until System UI is healthy after Appium activates an application.""" + try: + _wait_for_stable_system_ui( + serial, + deadline=time.monotonic() + max(timeout, stability_seconds * 3), + stability_seconds=stability_seconds, + anr_wait_action=anr_wait_action, + ) + except EmulatorRuntimeError: + running = next( + (item for item in list_running_emulators() if item.serial == serial), + None, + ) + _capture_failure_screenshot( + serial, + running.avd_name if running else "emulator", + ) + raise + + +def _ensure_android_emulator( + *, + requested_avd: str | None = None, + requested_serial: str | None = None, + headless: bool = False, + boot_timeout: int = DEFAULT_BOOT_TIMEOUT, + stability_seconds: int = DEFAULT_STABILITY_SECONDS, +) -> EmulatorTarget: + """Reuse a running AVD or launch and fully prepare one for Appium.""" + requested = (requested_avd or "").strip() + running = list_running_emulators() + if requested.lower() not in _AUTO_AVD_VALUES: + running = [item for item in running if item.avd_name == requested] + + serial_request = (requested_serial or "").strip() + ignored_serials = {*_AUTO_AVD_VALUES, "launch", "na", "n/a"} + if serial_request.lower() not in ignored_serials: + serial_matches = [item for item in running if item.serial == serial_request] + if serial_matches: + running = serial_matches + elif serial_request.startswith("emulator-"): + raise EmulatorRuntimeError( + f"Requested Android emulator '{serial_request}' is not connected." + ) + + if len(running) == 1: + logger.info( + "[emulator-runtime] Reusing running AVD %s (%s)", + running[0].avd_name or "unknown", + running[0].serial, + ) + return _prepare_running_target( + running[0], + headless=headless, + boot_timeout=boot_timeout, + stability_seconds=stability_seconds, + ) + if len(running) > 1: + choices = ", ".join( + f"{item.avd_name or 'unknown'} ({item.serial})" for item in running + ) + raise EmulatorRuntimeError( + f"Multiple Android emulators are running. Provide avd=. Found: {choices}" + ) + + avd_name = resolve_avd_name(requested) + baseline_serials = set(list_online_android_serials()) + last_error: Exception | None = None + + # Quick Boot first. If a restored snapshot leaves System UI unhealthy, + # retry once with a cold boot while preserving the AVD's user data. + for cold_boot in (False, True): + process = _spawn_emulator( + avd_name, + headless=headless, + cold_boot=cold_boot, + ) + boot_deadline = time.monotonic() + boot_timeout + serial = "" + try: + serial = _wait_for_serial( + avd_name, + process, + baseline_serials, + boot_deadline, + ) + _wait_for_android_boot(serial, boot_deadline) + _wait_for_stable_system_ui( + serial, + deadline=time.monotonic() + max(60, stability_seconds * 3), + stability_seconds=stability_seconds, + ) + logger.info( + "[emulator-runtime] AVD %s is ready for automation on %s", + avd_name, + serial, + ) + return EmulatorTarget( + serial=serial, + avd_name=avd_name, + launched_by_zeuz=True, + headless=headless, + ) + except EmulatorRuntimeError as exc: + last_error = exc + if serial: + _capture_failure_screenshot(serial, avd_name) + _stop_owned_emulator(avd_name, serial) + if not cold_boot: + logger.warning( + "[emulator-runtime] Quick Boot for %s was not healthy (%s). Retrying with snapshot loading disabled.", + avd_name, + exc, + ) + time.sleep(3) + continue + break + + detail = _read_log_tail(avd_name) + raise EmulatorRuntimeError( + f"Unable to prepare AVD '{avd_name}' for automation: {last_error}. {detail}" + ) + + +def ensure_android_emulator( + *, + requested_avd: str | None = None, + requested_serial: str | None = None, + headless: bool = False, + boot_timeout: int = DEFAULT_BOOT_TIMEOUT, + stability_seconds: int = DEFAULT_STABILITY_SECONDS, +) -> EmulatorTarget: + """Serialize AVD preparation so parallel runs cannot launch the same AVD twice.""" + with _LIFECYCLE_LOCK: + return _ensure_android_emulator( + requested_avd=requested_avd, + requested_serial=requested_serial, + headless=headless, + boot_timeout=boot_timeout, + stability_seconds=stability_seconds, + ) + + +def stop_all_owned_emulators() -> None: + with _REGISTRY_LOCK: + owned_names = list(_OWNED_PROCESSES) + if not owned_names: + return + try: + running_by_name = { + emulator.avd_name: emulator.serial for emulator in list_running_emulators() + } + except Exception: + running_by_name = {} + for avd_name in owned_names: + _stop_owned_emulator(avd_name, running_by_name.get(avd_name, "")) + + +atexit.register(stop_all_owned_emulators) diff --git a/Framework/install_handler/android/emulator_windows_linux.py b/Framework/install_handler/android/emulator_windows_linux.py index 173dfd5b..c578ebb1 100644 --- a/Framework/install_handler/android/emulator_windows_linux.py +++ b/Framework/install_handler/android/emulator_windows_linux.py @@ -5,9 +5,9 @@ import re import random from pathlib import Path -from settings import ZEUZ_NODE_DOWNLOADS_DIR from Framework.install_handler.utils import send_response, debug from Framework.install_handler.android.android_sdk import _get_sdk_root +from Framework.install_handler.android.emulator_manager import ensure_android_emulator from Framework.install_handler.install_log_config import get_logger logger = get_logger() @@ -195,56 +195,36 @@ async def get_available_avds() -> list[dict]: async def launch_avd(avd_name: str) -> bool: """ - Launch AVD using emulator command determined by OS. - Non-blocking - the emulator starts in the background. - Sends response to server on success or failure. + Launch an AVD and wait until it is safe for automation. """ try: - emulator_path = get_emulator_command() - - # Launch emulator in background using Popen (non-blocking) - # Popen returns immediately, so we can call it directly without blocking - process = subprocess.Popen( - [emulator_path, "-avd", avd_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True # Detach from parent process - ) - - logger.info("[installer][emulator] Launching AVD: %s... (PID: %s)", avd_name, process.pid) - - # Send success response to server await send_response({ "action": "status", "data": { "category": "AndroidEmulator", "name": avd_name, - "status": "installed", - "comment": f"Emulator {avd_name} is launching (PID: {process.pid})", + "status": "installing", + "comment": f"Starting {avd_name} and waiting for Android to become stable...", } }) - return True - - except FileNotFoundError: - error_msg = f"Emulator executable not found" - - logger.error("[installer][emulator] %s", error_msg) + target = await asyncio.to_thread( + ensure_android_emulator, + requested_avd=avd_name, + headless=False, + ) await send_response({ "action": "status", "data": { "category": "AndroidEmulator", "name": avd_name, - "status": "not installed", - "comment": f"Failed to launch {avd_name}: {error_msg}", + "status": "installed", + "comment": f"Emulator {avd_name} is ready on {target.serial}", } }) - return False + return True except Exception as e: error_msg = f"Failed to launch AVD {avd_name}: {e}" - logger.error("[installer][emulator] %s", error_msg) - import traceback - traceback.print_exc() await send_response({ "action": "status", "data": { @@ -1852,4 +1832,4 @@ async def create_avd_from_system_image(device_param: str) -> bool: "comment": error_msg, } }) - return False \ No newline at end of file + return False diff --git a/Framework/nodejs_appium_installer.py b/Framework/nodejs_appium_installer.py index 51d3593f..a3c599db 100644 --- a/Framework/nodejs_appium_installer.py +++ b/Framework/nodejs_appium_installer.py @@ -128,6 +128,14 @@ def get_npm_path(): return node_dir / "bin" / "npm" +def get_node_path(): + """Get the Node.js binary managed by ZeuZ.""" + node_dir = get_node_dir() + if platform.system() == "Windows": + return node_dir / "node.exe" + return node_dir / "bin" / "node" + + def get_appium_path(): """Get appium binary path.""" node_dir = get_node_dir() @@ -137,16 +145,91 @@ def get_appium_path(): return node_dir / "bin" / "appium" +def get_local_node_env(): + """Build an environment that always resolves ZeuZ's Node.js before system Node.js.""" + env = os.environ.copy() + node_dir = get_node_dir() + bin_dir = node_dir if platform.system() == "Windows" else node_dir / "bin" + path_parts = [ + part + for part in env.get("PATH", "").split(os.pathsep) + if part and Path(part) != bin_dir + ] + env["PATH"] = os.pathsep.join([str(bin_dir), *path_parts]) + return env + + +def _run_local(command, **kwargs): + kwargs.setdefault("env", get_local_node_env()) + return subprocess.run(command, **kwargs) + + +def _command_error(result): + output = (result.stderr or result.stdout or "").strip() + return output[-1000:] if output else f"exit code {result.returncode}" + + def install_drivers(drivers): - """Install specified Appium drivers.""" - appium_path = get_appium_path() + """Install only missing Appium drivers and verify the resulting state.""" + requested = list(dict.fromkeys(drivers)) + if not requested: + return True + + try: + installed = set(check_appium_drivers()) + except Exception as exc: + print(f"ERROR: Could not inspect installed Appium drivers: {exc}") + return False + + for driver in requested: + if driver in installed: + print(f"Appium driver {driver} is already installed; skipping") + continue - for driver in drivers: + print(f"Installing Appium driver {driver}...") try: - subprocess.run([str(appium_path), "driver", "install", driver], check=True) - print(f"Successfully installed {driver} driver") - except subprocess.CalledProcessError: - print(f"Warning: Failed to install {driver} driver, continuing...") + result = _run_local( + [str(get_appium_path()), "driver", "install", driver], + capture_output=True, + text=True, + timeout=900, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f"ERROR: Appium driver {driver} installation could not run: {exc}") + return False + + if result.returncode != 0: + # Another process may have installed it after our initial check. + try: + if driver in check_appium_drivers(): + print( + f"Appium driver {driver} became available during installation" + ) + installed.add(driver) + continue + except Exception: + pass + print( + f"ERROR: Failed to install Appium driver {driver}: " + f"{_command_error(result)}" + ) + return False + + print(f"Successfully installed Appium driver {driver}") + installed.add(driver) + + try: + verified = set(check_appium_drivers()) + except Exception as exc: + print(f"ERROR: Could not verify Appium drivers after installation: {exc}") + return False + + missing = [driver for driver in requested if driver not in verified] + if missing: + print(f"ERROR: Required Appium drivers are still missing: {', '.join(missing)}") + return False + return True def install_appium(): @@ -157,13 +240,43 @@ def install_appium(): raise Exception("npm not found. Install Node.js first.") print("Installing Appium...") - subprocess.run( - [str(npm_path), "install", "-g", "appium", "--strict-ssl=false"], check=True + result = _run_local( + [ + str(npm_path), + "install", + "-g", + "appium", + "--prefix", + str(get_node_dir()), + "--strict-ssl=false", + ], + capture_output=True, + text=True, + timeout=900, + check=False, ) + if result.returncode != 0: + raise RuntimeError(f"Appium installation failed: {_command_error(result)}") + + appium_path = get_appium_path() + version = _run_local( + [str(appium_path), "--version"], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if version.returncode != 0: + raise RuntimeError( + f"Appium was installed but is not executable: {_command_error(version)}" + ) + print(f"Appium {version.stdout.strip()} installed at {appium_path}") print("Installing Appium drivers...") - install_drivers(get_required_drivers()) + if not install_drivers(get_required_drivers()): + raise RuntimeError("One or more required Appium drivers could not be prepared") print("Appium installation completed") + return True def update_path(): @@ -175,8 +288,8 @@ def update_path(): bin_dir = str(node_dir / "bin") current_path = os.environ.get("PATH", "") - if bin_dir not in current_path: - os.environ["PATH"] = f"{bin_dir}{os.pathsep}{current_path}" + path_parts = [part for part in current_path.split(os.pathsep) if part != bin_dir] + os.environ["PATH"] = os.pathsep.join([bin_dir, *path_parts]) def get_required_drivers(): @@ -198,52 +311,65 @@ def check_appium_drivers(): return [] try: - result = subprocess.run( - [str(appium_path), "driver", "list", "--json"], + result = _run_local( + [str(appium_path), "driver", "list", "--installed", "--json"], capture_output=True, text=True, + timeout=60, + check=False, ) + if result.returncode != 0: + raise RuntimeError(_command_error(result)) drivers_data = json.loads(result.stdout) + if not isinstance(drivers_data, dict): + raise ValueError("Appium returned an invalid driver list") return [ - name for name, info in drivers_data.items() if info.get("installed", False) + name + for name, info in drivers_data.items() + if isinstance(info, dict) and info.get("installed", False) ] - except: # noqa: E722 - return [] + except (OSError, subprocess.SubprocessError, ValueError) as exc: + raise RuntimeError(f"Unable to query Appium drivers: {exc}") from exc def check_installations(): """Check if Node.js, Appium and required drivers are installed.""" - node_dir = get_node_dir() - node_bin = node_dir / ("node.exe" if platform.system() == "Windows" else "bin/node") + node_bin = get_node_path() + node_installed = False + if node_bin.exists(): + result = _run_local( + [str(node_bin), "--version"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + node_installed = result.returncode == 0 - # Check for Appium in global npm modules - npm_path = get_npm_path() + appium_path = get_appium_path() appium_installed = False - - if npm_path.exists(): - try: - result = subprocess.run( - [str(npm_path), "list", "-g", "--json", "appium"], - capture_output=True, - text=True, - ) - npm_data = json.loads(result.stdout) - appium_installed = "appium" in npm_data.get("dependencies", {}) - except: # noqa: E722 - pass + if node_installed and appium_path.exists(): + result = _run_local( + [str(appium_path), "--version"], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + appium_installed = result.returncode == 0 # Check drivers required_drivers = get_required_drivers() installed_drivers = check_appium_drivers() if appium_installed else [] missing_drivers = [d for d in required_drivers if d not in installed_drivers] - return node_bin.exists(), appium_installed, missing_drivers + return node_installed, appium_installed, missing_drivers def install_missing_drivers(missing_drivers): """Install missing Appium drivers.""" print("Installing missing Appium drivers...") - install_drivers(missing_drivers) + return install_drivers(missing_drivers) def check_and_remove_global_appium(): @@ -330,14 +456,33 @@ def setup_nodejs_appium(): if not appium_installed: install_appium() elif missing_drivers: - install_missing_drivers(missing_drivers) + if not install_missing_drivers(missing_drivers): + raise RuntimeError( + "Required Appium drivers could not be installed: " + f"{', '.join(missing_drivers)}" + ) else: print("Appium and all required drivers already installed") - print("Node.js and Appium setup completed successfully") + node_installed, appium_installed, missing_drivers = check_installations() + if not node_installed: + raise RuntimeError("ZeuZ-managed Node.js could not be verified") + if not appium_installed: + raise RuntimeError("ZeuZ-managed Appium could not be verified") + if missing_drivers: + raise RuntimeError( + "Required Appium drivers are missing after setup: " + f"{', '.join(missing_drivers)}" + ) + + installed_drivers = check_appium_drivers() + print( + "Node.js and Appium setup verified successfully " + f"(drivers: {', '.join(installed_drivers) or 'none'})" + ) return True except Exception as e: - print(f"Error during setup: {e}") + print(f"ERROR: Node.js/Appium setup was not completed: {e}") return False diff --git a/node_cli.py b/node_cli.py index 5655d7d4..af768bd6 100755 --- a/node_cli.py +++ b/node_cli.py @@ -127,10 +127,11 @@ def setup_nodejs_appium(): try: import nodejs_appium_installer - nodejs_appium_installer.setup_nodejs_appium() + return nodejs_appium_installer.setup_nodejs_appium() except Exception as e: print(f"Warning: Failed to setup Node.js and Appium: {e}") print("Continuing without Node.js/Appium setup...") + return False # Tells node whether it should run a test set/deployment only once and quit. @@ -1385,7 +1386,11 @@ async def main(): os._exit(1) if not disable_mobile_install: - setup_nodejs_appium() + if not setup_nodejs_appium(): + print( + "WARNING: The ZeuZ Node will continue, but local mobile actions " + "are unavailable until Node.js/Appium setup succeeds." + ) update_java_path() update_android_sdk_path() update_outdated_modules() diff --git a/tests/test_all_device_info.py b/tests/test_all_device_info.py new file mode 100644 index 00000000..2839dfc2 --- /dev/null +++ b/tests/test_all_device_info.py @@ -0,0 +1,29 @@ +from Framework.Utilities import All_Device_Info + + +def test_missing_adb_returns_no_devices_without_invoking_a_shell(monkeypatch): + monkeypatch.setattr(All_Device_Info, "_get_adb_executable", lambda: None) + monkeypatch.setattr( + All_Device_Info.subprocess, + "check_output", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("adb must not be invoked when it is unavailable") + ), + ) + + assert All_Device_Info.get_all_connected_android_info() == {} + + +def test_adb_helper_uses_an_argument_list_without_shell(monkeypatch): + calls = [] + + def check_output(command, **kwargs): + calls.append((command, kwargs)) + return "List of devices attached\n" + + monkeypatch.setattr(All_Device_Info.subprocess, "check_output", check_output) + + All_Device_Info._adb_output("/android/platform-tools/adb", "devices") + + assert calls[0][0] == ["/android/platform-tools/adb", "devices"] + assert "shell" not in calls[0][1] diff --git a/tests/test_android_emulator_manager.py b/tests/test_android_emulator_manager.py new file mode 100644 index 00000000..2455eedd --- /dev/null +++ b/tests/test_android_emulator_manager.py @@ -0,0 +1,244 @@ +from types import SimpleNamespace + +import pytest + +from Framework.deploy_handler.adapter import normalize_mobile_execution +from Framework.install_handler.android import emulator_manager + + +def test_parse_adb_devices_returns_only_online_devices(): + output = """List of devices attached +emulator-5554\tdevice product:sdk_gphone model:sdk_gphone +R58M123\toffline +R58M456\tunauthorized + +""" + + assert emulator_manager._parse_adb_devices(output) == ["emulator-5554"] + + +def test_headless_command_preserves_data_and_can_skip_snapshot_load(monkeypatch): + monkeypatch.setattr( + emulator_manager, + "get_emulator_path", + lambda: emulator_manager.Path("/android/emulator"), + ) + + command = emulator_manager._build_emulator_command( + "Pixel_7", + headless=True, + cold_boot=True, + ) + + assert command == [ + "/android/emulator", + "-avd", + "Pixel_7", + "-no-audio", + "-no-boot-anim", + "-no-window", + "-no-snapshot-load", + ] + assert "-wipe-data" not in command + + +def test_resolve_avd_uses_first_installed_avd_when_multiple_exist(monkeypatch): + monkeypatch.delenv("ZEUZ_ANDROID_DEFAULT_AVD", raising=False) + monkeypatch.setattr( + emulator_manager, + "list_avd_names", + lambda: ["Pixel_7", "Tablet_API_36"], + ) + + assert emulator_manager.resolve_avd_name("auto") == "Pixel_7" + + +def test_resolve_avd_uses_configured_default(monkeypatch): + monkeypatch.setenv("ZEUZ_ANDROID_DEFAULT_AVD", "Pixel_7") + monkeypatch.setattr( + emulator_manager, + "list_avd_names", + lambda: ["Pixel_7", "Tablet_API_36"], + ) + + assert emulator_manager.resolve_avd_name("auto") == "Pixel_7" + + +def test_system_ui_anr_dismissal_clicks_only_wait(monkeypatch): + xml = """ + + + + + +""" + commands = [] + monkeypatch.setattr(emulator_manager, "_dump_ui", lambda serial: xml) + monkeypatch.setattr(emulator_manager.time, "sleep", lambda seconds: None) + + def fake_run(command, **kwargs): + commands.append(command) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(emulator_manager, "_run", fake_run) + + assert emulator_manager._dismiss_system_ui_anr("emulator-5554") is True + assert commands + assert commands[0][-5:] == ["input", "touchscreen", "tap", "200", "150"] + + +def test_stability_wait_captures_anr_and_uses_supplied_wait_action(monkeypatch): + clock = [0.0] + focus_values = iter( + [ + "mCurrentFocus=Window{ Application Error: com.android.systemui }", + "mCurrentFocus=Window{ com.example/.MainActivity }", + "mCurrentFocus=Window{ com.example/.MainActivity }", + ] + ) + screenshots = [] + wait_actions = [] + + monkeypatch.setattr( + emulator_manager, + "_run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="1234", + stderr="", + ), + ) + monkeypatch.setattr( + emulator_manager, + "_focused_window", + lambda serial: next(focus_values), + ) + monkeypatch.setattr( + emulator_manager, + "_capture_failure_screenshot", + lambda serial, name: screenshots.append((serial, name)), + ) + monkeypatch.setattr(emulator_manager.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr( + emulator_manager.time, + "sleep", + lambda seconds: clock.__setitem__(0, clock[0] + seconds), + ) + + emulator_manager._wait_for_stable_system_ui( + "emulator-5554", + deadline=20, + stability_seconds=2, + anr_wait_action=lambda: wait_actions.append("wait") or True, + ) + + assert wait_actions == ["wait"] + assert screenshots == [ + ("emulator-5554", "system-ui-anr-emulator-5554"), + ] + + +def test_unhealthy_quick_boot_retries_with_snapshot_loading_disabled(monkeypatch): + spawned_modes = [] + stopped = [] + stability_attempts = 0 + process = SimpleNamespace(poll=lambda: None, pid=1234) + + monkeypatch.setattr(emulator_manager, "list_running_emulators", lambda: []) + monkeypatch.setattr( + emulator_manager, "resolve_avd_name", lambda requested: "Pixel_7" + ) + monkeypatch.setattr(emulator_manager, "list_online_android_serials", lambda: []) + monkeypatch.setattr( + emulator_manager, + "_spawn_emulator", + lambda avd_name, headless, cold_boot: ( + spawned_modes.append((headless, cold_boot)) or process + ), + ) + monkeypatch.setattr( + emulator_manager, + "_wait_for_serial", + lambda avd_name, process, baseline_serials, deadline: "emulator-5554", + ) + monkeypatch.setattr(emulator_manager, "_wait_for_android_boot", lambda *args: None) + + def wait_for_stability(*args, **kwargs): + nonlocal stability_attempts + stability_attempts += 1 + if stability_attempts == 1: + raise emulator_manager.SystemUiAnrError("System UI is not responding") + + monkeypatch.setattr( + emulator_manager, + "_wait_for_stable_system_ui", + wait_for_stability, + ) + monkeypatch.setattr( + emulator_manager, "_capture_failure_screenshot", lambda *args: None + ) + monkeypatch.setattr( + emulator_manager, + "_stop_owned_emulator", + lambda avd_name, serial="": stopped.append((avd_name, serial)), + ) + monkeypatch.setattr(emulator_manager.time, "sleep", lambda seconds: None) + + target = emulator_manager.ensure_android_emulator( + requested_avd="auto", + headless=True, + ) + + assert target.serial == "emulator-5554" + assert spawned_modes == [(True, False), (True, True)] + assert stopped == [("Pixel_7", "emulator-5554")] + + +def test_requested_serial_selects_one_running_emulator(monkeypatch): + running = [ + emulator_manager.RunningEmulator("emulator-5554", "Pixel_7"), + emulator_manager.RunningEmulator("emulator-5556", "Tablet_API_36"), + ] + prepared = [] + monkeypatch.setattr(emulator_manager, "list_running_emulators", lambda: running) + + def prepare(target, **kwargs): + prepared.append(target.serial) + return emulator_manager.EmulatorTarget( + serial=target.serial, + avd_name=target.avd_name, + launched_by_zeuz=False, + headless=kwargs["headless"], + ) + + monkeypatch.setattr(emulator_manager, "_prepare_running_target", prepare) + + target = emulator_manager.ensure_android_emulator( + requested_serial="emulator-5556", + headless=True, + ) + + assert target.serial == "emulator-5556" + assert prepared == ["emulator-5556"] + + +@pytest.mark.parametrize( + ("selection", "platform", "target", "headless"), + [ + ("Android", "Android", "auto", False), + ("Android Headless", "Android", "emulator", True), + ("Android-Headless", "Android", "emulator", True), + ("iOS Simulator", "iOS", "simulator", False), + ], +) +def test_mobile_selection_is_normalized_for_action_filtering( + selection, + platform, + target, + headless, +): + normalized_platform, execution = normalize_mobile_execution(selection) + + assert normalized_platform == platform + assert execution["target"] == target + assert execution["headless"] is headless diff --git a/tests/test_nodejs_appium_installer.py b/tests/test_nodejs_appium_installer.py new file mode 100644 index 00000000..256abf36 --- /dev/null +++ b/tests/test_nodejs_appium_installer.py @@ -0,0 +1,178 @@ +import asyncio +import json +import subprocess + +from Framework import nodejs_appium_installer as installer +from Framework.install_handler.android import appium as appium_service + + +def completed(command, returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess( + command, + returncode, + stdout=stdout, + stderr=stderr, + ) + + +def test_check_installations_verifies_managed_binaries_and_drivers( + monkeypatch, + tmp_path, +): + node_path = tmp_path / "node" + appium_path = tmp_path / "appium" + node_path.touch() + appium_path.touch() + + monkeypatch.setattr(installer, "get_node_path", lambda: node_path) + monkeypatch.setattr(installer, "get_appium_path", lambda: appium_path) + + def run_local(command, **kwargs): + if command == [str(node_path), "--version"]: + return completed(command, stdout="v22.20.0\n") + if command == [str(appium_path), "--version"]: + return completed(command, stdout="3.5.2\n") + if command == [ + str(appium_path), + "driver", + "list", + "--installed", + "--json", + ]: + return completed( + command, + stdout=json.dumps({"uiautomator2": {"installed": True}}), + ) + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(installer, "_run_local", run_local) + + assert installer.check_installations() == (True, True, []) + + +def test_install_drivers_skips_an_existing_driver(monkeypatch): + checks = [] + monkeypatch.setattr( + installer, + "check_appium_drivers", + lambda: checks.append(True) or ["uiautomator2"], + ) + monkeypatch.setattr( + installer, + "_run_local", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("driver install must not run") + ), + ) + + assert installer.install_drivers(["uiautomator2"]) is True + assert len(checks) == 2 + + +def test_install_appium_rechecks_drivers_instead_of_blindly_installing( + monkeypatch, + tmp_path, +): + node_dir = tmp_path / "nodejs" + npm_path = node_dir / "bin" / "npm" + appium_path = node_dir / "bin" / "appium" + npm_path.parent.mkdir(parents=True) + npm_path.touch() + appium_path.touch() + commands = [] + + monkeypatch.setattr(installer, "get_node_dir", lambda: node_dir) + monkeypatch.setattr(installer, "get_npm_path", lambda: npm_path) + monkeypatch.setattr(installer, "get_appium_path", lambda: appium_path) + + def run_local(command, **kwargs): + commands.append(command) + if command[:3] == [str(npm_path), "install", "-g"]: + return completed(command, stdout="installed") + if command == [str(appium_path), "--version"]: + return completed(command, stdout="3.5.2\n") + if command == [ + str(appium_path), + "driver", + "list", + "--installed", + "--json", + ]: + return completed( + command, + stdout=json.dumps({"uiautomator2": {"installed": True}}), + ) + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(installer, "_run_local", run_local) + monkeypatch.setattr(installer, "get_required_drivers", lambda: ["uiautomator2"]) + + assert installer.install_appium() is True + assert not any(command[1:3] == ["driver", "install"] for command in commands) + assert "--prefix" in commands[0] + assert str(node_dir) in commands[0] + + +def test_install_drivers_reports_a_real_install_failure(monkeypatch): + checks = iter([[], []]) + monkeypatch.setattr(installer, "check_appium_drivers", lambda: next(checks)) + monkeypatch.setattr( + installer, + "_run_local", + lambda command, **kwargs: completed( + command, + returncode=1, + stderr="network unavailable", + ), + ) + + assert installer.install_drivers(["uiautomator2"]) is False + + +def test_setup_does_not_claim_success_when_a_required_driver_fails( + monkeypatch, + capsys, +): + monkeypatch.setattr(installer, "check_and_remove_global_appium", lambda: None) + monkeypatch.setattr(installer, "update_path", lambda: None) + monkeypatch.setattr( + installer, + "check_installations", + lambda: (True, True, ["uiautomator2"]), + ) + monkeypatch.setattr(installer, "install_missing_drivers", lambda drivers: False) + + assert installer.setup_nodejs_appium() is False + output = capsys.readouterr().out + assert "setup was not completed" in output + assert "setup verified successfully" not in output + + +def test_local_node_environment_takes_precedence(monkeypatch, tmp_path): + node_dir = tmp_path / "nodejs" + monkeypatch.setattr(installer, "get_node_dir", lambda: node_dir) + monkeypatch.setenv("PATH", f"/system/bin:{node_dir / 'bin'}:/other/bin") + monkeypatch.setattr(installer.platform, "system", lambda: "Linux") + + path_parts = installer.get_local_node_env()["PATH"].split(installer.os.pathsep) + + assert path_parts[0] == str(node_dir / "bin") + assert path_parts.count(str(node_dir / "bin")) == 1 + + +def test_appium_ui_status_reports_missing_driver(monkeypatch): + responses = [] + monkeypatch.setattr( + appium_service, + "check_installations", + lambda: (True, True, ["uiautomator2"]), + ) + + async def send_response(payload): + responses.append(payload) + + monkeypatch.setattr(appium_service, "send_response", send_response) + + assert asyncio.run(appium_service.check_status()) is False + assert responses[-1]["data"]["status"] == "not installed" + assert "uiautomator2" in responses[-1]["data"]["comment"] diff --git a/tests/test_screenshot_without_driver.py b/tests/test_screenshot_without_driver.py new file mode 100644 index 00000000..83bb00d3 --- /dev/null +++ b/tests/test_screenshot_without_driver.py @@ -0,0 +1,39 @@ +import asyncio + +from Framework.Utilities import CommonUtil + + +def test_post_action_screenshot_is_skipped_when_mobile_driver_was_not_created( + monkeypatch, + tmp_path, +): + logs = [] + thread_calls = [] + monkeypatch.setattr(CommonUtil, "ws_ss_log", True) + monkeypatch.setattr(CommonUtil, "performance_testing", False) + monkeypatch.setattr(CommonUtil, "upload_on_fail", False) + monkeypatch.setattr(CommonUtil, "screen_capture_type", "mobile") + monkeypatch.setattr(CommonUtil, "screen_capture_driver", None) + monkeypatch.setattr( + CommonUtil.ConfigModule, + "get_config_value", + lambda section, key, *args: ( + "true" if key == "take_screenshot" else str(tmp_path) + ), + ) + monkeypatch.setattr( + CommonUtil, + "ExecLog", + lambda module, message, level, *args, **kwargs: logs.append((message, level)), + ) + + async def thread_screenshot(*args, **kwargs): + thread_calls.append((args, kwargs)) + + monkeypatch.setattr(CommonUtil, "Thread_ScreenShot", thread_screenshot) + + asyncio.run(CommonUtil.TakeScreenShot("launch_application")) + + assert thread_calls == [] + assert any("driver is not available" in message for message, _ in logs) + assert not any(level == 3 for _, level in logs)