Skip to content

Commit b4ff069

Browse files
authored
Merge pull request #636 from AutomationSolutionz/req-24-ios-inspector
[REQ-24] iOS Inspector
2 parents 67c3846 + 788c3a7 commit b4ff069

3 files changed

Lines changed: 294 additions & 5 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,7 @@ Apps/Windows/inspector.exe
6363
Apps/Windows/Element.xml
6464
Framework/settings.conf.lock
6565
Framework/Built_In_Automation/Desktop/Linux/latest_app.txt
66-
**/linux_screen.png
66+
**/linux_screen.png
67+
**/ios_screen.png
68+
**/ios_ui.xml
69+
**/ui.xml

node_cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from Framework.install_handler.android.java import update_java_path
3939
from settings import ZEUZ_NODE_PRIVATE_RSA_KEYS_DIR
4040
from Framework.install_handler.long_poll_handler import InstallHandler
41-
from server.mobile import upload_android_ui_dump
41+
from server.mobile import upload_android_ui_dump, upload_ios_ui_dump
4242
from Framework.install_handler.android.android_sdk import update_android_sdk_path
4343

4444
def adjust_python_path():
@@ -1348,6 +1348,7 @@ async def main():
13481348
update_outdated_modules()
13491349
asyncio.create_task(start_server())
13501350
asyncio.create_task(upload_android_ui_dump())
1351+
asyncio.create_task(upload_ios_ui_dump())
13511352
asyncio.create_task(delete_old_automationlog_folders())
13521353
await destroy_session()
13531354

server/mobile.py

Lines changed: 288 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,48 @@
44
import os
55
import shutil
66
import subprocess
7+
import base64
8+
import json
79
from typing import Literal
10+
import asyncio
11+
import socket
12+
import xml.etree.ElementTree as ET
813

914
import requests
1015
from androguard.core.apk import APK
1116
from fastapi import APIRouter, UploadFile, File
1217
from pydantic import BaseModel
1318

19+
import sys
20+
import logging
1421
from Framework.Utilities import CommonUtil, ConfigModule
1522
from settings import ZEUZ_NODE_DOWNLOADS_DIR
1623

24+
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'Framework', 'Built_In_Automation', 'Mobile', 'CrossPlatform', 'Appium'))
1725
ADB_PATH = "adb" # Ensure ADB is in PATH
1826
UI_XML_PATH = "ui.xml"
1927
SCREENSHOT_PATH = "screen.png"
28+
IOS_SCREENSHOT_PATH = "ios_screen.png"
29+
IOS_XML_PATH = "ios_ui.xml"
2030

2131
router = APIRouter(prefix="/mobile", tags=["mobile"])
2232

2333

34+
def is_wda_running(port: int) -> bool:
35+
"""Check if WebDriverAgent is running on given port."""
36+
try:
37+
response = requests.get(f"http://localhost:{port}/status", timeout=1)
38+
return response.status_code == 200
39+
except:
40+
return False
41+
42+
2443
class InspectorResponse(BaseModel):
2544
"""Response model for the /inspector endpoint."""
26-
2745
status: Literal["ok", "error"] = "ok"
2846
ui_xml: str | None = None
2947
screenshot: str | None = None # Base64 encoded image
48+
bundle_identifier: str | None = None
3049
error: str | None = None
3150

3251

@@ -36,8 +55,15 @@ class DeviceInfo(BaseModel):
3655
serial: str
3756
status: str
3857
name: str | None = None
39-
# model: str | None = None
40-
# product: str | None = None
58+
59+
60+
class IOSDeviceInfo(BaseModel):
61+
"""Model for iOS device information."""
62+
udid: str
63+
name: str
64+
state: str
65+
runtime: str
66+
device_type: str
4167

4268

4369
@router.get("/devices", response_model=list[DeviceInfo])
@@ -68,6 +94,35 @@ def get_devices():
6894
return []
6995

7096

97+
@router.get("/ios/devices", response_model=list[IOSDeviceInfo])
98+
def get_ios_devices():
99+
"""Get list of booted iOS simulators only."""
100+
try:
101+
result = subprocess.run(
102+
["xcrun", "simctl", "list", "devices", "-j"],
103+
capture_output=True, text=True, check=True
104+
)
105+
106+
devices_data = json.loads(result.stdout)
107+
ios_devices = []
108+
109+
for runtime, devices in devices_data.get("devices", {}).items():
110+
for device in devices:
111+
# Only return booted devices
112+
if device.get("isAvailable", False) and device.get("state") == "Booted":
113+
ios_devices.append(IOSDeviceInfo(
114+
udid=device["udid"],
115+
name=device["name"],
116+
state=device["state"],
117+
runtime=runtime,
118+
device_type=device.get("deviceTypeIdentifier", "Unknown")
119+
))
120+
121+
return ios_devices
122+
except Exception as e:
123+
return []
124+
125+
71126
@router.get("/inspect")
72127
def inspect(device_serial: str | None = None):
73128
"""Get the Mobile DOM and screenshot."""
@@ -92,6 +147,98 @@ def inspect(device_serial: str | None = None):
92147
return InspectorResponse(status="error", error=str(e))
93148

94149

150+
151+
@router.post("/ios/start-services")
152+
def start_ios_services():
153+
try:
154+
ios_devices = get_ios_devices()
155+
if not ios_devices:
156+
return {"status": "error", "error": "No booted iOS simulators"}
157+
158+
device_udid = ios_devices[0].udid
159+
160+
# Check if WDA is already running
161+
wda_port = 8100
162+
tries = 0
163+
while tries < 20:
164+
if not is_wda_running(wda_port):
165+
break
166+
wda_port += 2
167+
tries += 1
168+
169+
if tries >= 20:
170+
return {"status": "error", "error": "No available WDA ports"}
171+
172+
result = subprocess.run(
173+
["xcrun", "simctl", "launch", device_udid, "com.facebook.WebDriverAgentRunner.xctrunner"],
174+
capture_output=True, text=True
175+
)
176+
177+
if result.returncode != 0:
178+
return {"status": "error", "error": f"Failed to launch WDA."}
179+
180+
return {"status": "ok", "port": wda_port}
181+
182+
except Exception as e:
183+
logging.exception("Failed to start iOS services")
184+
return {"status": "error", "error": "Failed to start iOS services"}
185+
186+
187+
def extract_bundle_id_from_xml(xml_content: str) -> str | None:
188+
try:
189+
root = ET.fromstring(xml_content)
190+
return root.get('bundleId')
191+
except Exception:
192+
return None
193+
194+
195+
@router.get("/ios/inspect")
196+
def inspect_ios(device_udid: str | None = None):
197+
"""Get iOS simulator screenshot and XML hierarchy."""
198+
try:
199+
if not device_udid:
200+
ios_devices = get_ios_devices()
201+
if not ios_devices:
202+
return InspectorResponse(
203+
status="error",
204+
error="No iOS simulators available"
205+
)
206+
207+
# Find first booted device
208+
booted_devices = [d for d in ios_devices if d.state == "Booted"]
209+
if not booted_devices:
210+
return InspectorResponse(
211+
status="error",
212+
error="No booted iOS simulators found. Please start an iOS simulator."
213+
)
214+
device_udid = booted_devices[0].udid
215+
216+
capture_ios_ui_dump(device_udid)
217+
capture_ios_screenshot(device_udid)
218+
219+
with open(IOS_XML_PATH, 'r', encoding='utf-8') as xml_file:
220+
xml_content = xml_file.read()
221+
222+
# Extract bundle identifier from XML content
223+
bundle_id = extract_bundle_id_from_xml(xml_content)
224+
225+
with open(IOS_SCREENSHOT_PATH, 'rb') as img_file:
226+
screenshot_bytes = img_file.read()
227+
screenshot_base64 = base64.b64encode(screenshot_bytes).decode('utf-8')
228+
229+
return InspectorResponse(
230+
status="ok",
231+
ui_xml=xml_content,
232+
screenshot=screenshot_base64,
233+
bundle_identifier=bundle_id
234+
)
235+
except Exception as e:
236+
return InspectorResponse(
237+
status="error",
238+
error=str(e)
239+
)
240+
241+
95242
@router.get("/dump/driver")
96243
def dump_driver():
97244
"""Dump the current driver."""
@@ -167,6 +314,101 @@ def capture_screenshot(device_serial: str | None = None):
167314
return
168315

169316

317+
def capture_ios_screenshot(device_udid: str):
318+
try:
319+
screenshot_path = os.path.abspath(IOS_SCREENSHOT_PATH)
320+
321+
if os.path.exists(screenshot_path):
322+
os.remove(screenshot_path)
323+
324+
result = subprocess.run(
325+
["xcrun", "simctl", "io", device_udid, "screenshot", "--type=png", screenshot_path],
326+
capture_output=True, text=True, check=True
327+
)
328+
329+
if not os.path.exists(screenshot_path):
330+
raise Exception("Screenshot file was not created")
331+
332+
return True
333+
except subprocess.CalledProcessError as e:
334+
raise Exception(f"Failed to capture iOS screenshot: {e.stderr}")
335+
except Exception as e:
336+
raise Exception(f"Failed to capture iOS screenshot: {str(e)}")
337+
338+
339+
def get_real_ios_hierarchy(device_udid: str):
340+
try:
341+
import requests
342+
343+
wda_port = 8100
344+
tries = 0
345+
346+
while tries < 20:
347+
try:
348+
wda_url = f"http://localhost:{wda_port}"
349+
350+
# Quick status check
351+
status_response = requests.get(f"{wda_url}/status", timeout=1)
352+
if status_response.status_code != 200:
353+
wda_port += 2
354+
tries += 1
355+
continue
356+
357+
# existing sessions first
358+
sessions_response = requests.get(f"{wda_url}/sessions", timeout=1)
359+
if sessions_response.status_code == 200:
360+
sessions = sessions_response.json()
361+
if sessions and len(sessions) > 0:
362+
session_id = sessions[0]['id']
363+
source_response = requests.get(f"{wda_url}/session/{session_id}/source", timeout=3)
364+
if source_response.status_code == 200:
365+
return source_response.text
366+
367+
# direct source
368+
source_response = requests.get(f"{wda_url}/source", timeout=2)
369+
if source_response.status_code == 200:
370+
return source_response.text
371+
372+
except:
373+
wda_port += 2
374+
tries += 1
375+
continue
376+
377+
except:
378+
pass
379+
380+
return None
381+
382+
383+
def capture_ios_ui_dump(device_udid: str):
384+
real_hierarchy = get_real_ios_hierarchy(device_udid)
385+
if real_hierarchy:
386+
try:
387+
import json
388+
json_data = json.loads(real_hierarchy)
389+
xml_content = json_data.get("value", real_hierarchy)
390+
except:
391+
xml_content = real_hierarchy
392+
393+
with open(IOS_XML_PATH, 'w', encoding='utf-8') as xml_file:
394+
xml_file.write(xml_content)
395+
return
396+
397+
# Fallback to Appium driver
398+
try:
399+
from Framework.Built_In_Automation.Mobile.CrossPlatform.Appium.BuiltInFunctions import appium_driver
400+
if appium_driver is not None:
401+
page_src = appium_driver.page_source
402+
with open(IOS_XML_PATH, 'w', encoding='utf-8') as xml_file:
403+
xml_file.write(page_src)
404+
return
405+
except:
406+
pass
407+
408+
# No real source available
409+
raise Exception("iOS service error. Make sure simulator is running.")
410+
411+
170412
async def upload_android_ui_dump():
171413
prev_xml_hash = ""
172414
while True:
@@ -214,6 +456,49 @@ async def upload_android_ui_dump():
214456
await asyncio.sleep(5)
215457

216458

459+
async def upload_ios_ui_dump():
460+
prev_xml_hash = ""
461+
while True:
462+
try:
463+
ios_devices = get_ios_devices()
464+
if not ios_devices:
465+
await asyncio.sleep(5)
466+
continue
467+
468+
device_udid = ios_devices[0].udid
469+
capture_ios_ui_dump(device_udid)
470+
471+
try:
472+
with open(IOS_XML_PATH, 'r', encoding='utf-8') as xml_file:
473+
xml_content = xml_file.read()
474+
xml_content = xml_content.replace("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>", "", 1)
475+
new_xml_hash = hashlib.sha256(xml_content.encode('utf-8')).hexdigest()
476+
# Don't upload if the content hasn't changed
477+
if prev_xml_hash == new_xml_hash:
478+
await asyncio.sleep(5)
479+
continue
480+
prev_xml_hash = new_xml_hash
481+
482+
except FileNotFoundError:
483+
await asyncio.sleep(5)
484+
continue
485+
486+
url = ConfigModule.get_config_value("Authentication", "server_address").strip() + "/node_ai_contents/"
487+
apiKey = ConfigModule.get_config_value("Authentication", "api-key").strip()
488+
res = requests.post(
489+
url,
490+
headers={"X-Api-Key": apiKey},
491+
json={
492+
"dom_mob": {"dom": xml_content},
493+
"node_id": CommonUtil.MachineInfo().getLocalUser().lower()
494+
})
495+
if res.ok:
496+
CommonUtil.ExecLog("", "UI dump uploaded successfully", iLogLevel=1)
497+
except Exception as e:
498+
CommonUtil.ExecLog("", f"Error uploading iOS UI dump: {str(e)}", iLogLevel=3)
499+
await asyncio.sleep(5)
500+
501+
217502
@router.post("/apk-upload")
218503
def handle_apk_upload(file: UploadFile = File(...)):
219504
dir_path = f"{ZEUZ_NODE_DOWNLOADS_DIR}/apk"

0 commit comments

Comments
 (0)