screenforge 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import config.config as config
|
|
10
|
+
from common.logs import log
|
|
11
|
+
|
|
12
|
+
from .base_adapter import BasePlatformAdapter
|
|
13
|
+
|
|
14
|
+
_SESSION_FILE = os.path.abspath(os.path.join("report", "android_session.json"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_session() -> dict | None:
|
|
18
|
+
if not os.path.exists(_SESSION_FILE):
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
with open(_SESSION_FILE, "r") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_session(serial: str) -> None:
|
|
28
|
+
os.makedirs(os.path.dirname(_SESSION_FILE), exist_ok=True)
|
|
29
|
+
with open(_SESSION_FILE, "w") as f:
|
|
30
|
+
json.dump({"serial": serial, "ts": time.time()}, f)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clear_session() -> None:
|
|
34
|
+
if os.path.exists(_SESSION_FILE):
|
|
35
|
+
os.remove(_SESSION_FILE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_device_online(serial: str) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["adb", "devices"], capture_output=True, text=True, timeout=5,
|
|
42
|
+
)
|
|
43
|
+
for line in result.stdout.strip().splitlines()[1:]:
|
|
44
|
+
parts = line.split()
|
|
45
|
+
if len(parts) >= 2 and parts[1] == "device":
|
|
46
|
+
if not serial or parts[0] == serial:
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AndroidU2Adapter(BasePlatformAdapter):
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__()
|
|
57
|
+
self._serial = config.ANDROID_SERIAL
|
|
58
|
+
self._scrcpy_process = None
|
|
59
|
+
|
|
60
|
+
def setup(self):
|
|
61
|
+
log.info("⏳ [Setup] Initializing Android (u2) device...")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
import uiautomator2 as u2
|
|
65
|
+
except ImportError:
|
|
66
|
+
log.error(
|
|
67
|
+
"❌ [E050] uiautomator2 not installed. "
|
|
68
|
+
"Fix: pip install screenforge[android] or pip install uiautomator2"
|
|
69
|
+
)
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
"uiautomator2 not installed. Run: pip install screenforge[android]"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if self._try_reconnect(u2):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
self._connect_fresh(u2)
|
|
78
|
+
|
|
79
|
+
def _try_reconnect(self, u2_module) -> bool:
|
|
80
|
+
session = _read_session()
|
|
81
|
+
if not session:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
serial = session.get("serial", "")
|
|
85
|
+
if not serial or not _is_device_online(serial):
|
|
86
|
+
log.info("⚠️ [System] Previous Android session device offline, connecting fresh")
|
|
87
|
+
_clear_session()
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
self.driver = u2_module.connect(serial)
|
|
92
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
93
|
+
info = self.driver.info
|
|
94
|
+
log.info(
|
|
95
|
+
f"✅ [System] Reconnected to Android device "
|
|
96
|
+
f"({info.get('productName', 'unknown')}, serial: {serial})"
|
|
97
|
+
)
|
|
98
|
+
self._serial = serial
|
|
99
|
+
return True
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log.info(f"⚠️ [System] Reconnect failed ({e}), connecting fresh")
|
|
102
|
+
_clear_session()
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def _connect_fresh(self, u2_module):
|
|
106
|
+
serial = self._serial
|
|
107
|
+
|
|
108
|
+
if serial and not _is_device_online(serial):
|
|
109
|
+
log.error(
|
|
110
|
+
f"❌ [E051] Android device '{serial}' not found or offline. "
|
|
111
|
+
"Fix: check 'adb devices' output and ensure the device is connected"
|
|
112
|
+
)
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"Android device '{serial}' not found. Run 'adb devices' to verify."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not serial and not _is_device_online(""):
|
|
118
|
+
log.error(
|
|
119
|
+
"❌ [E052] No Android device connected. "
|
|
120
|
+
"Fix: connect a device via USB or start an emulator, then run 'adb devices'"
|
|
121
|
+
)
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"No Android device connected. Run 'adb devices' to verify."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
connect_arg = serial if serial else None
|
|
128
|
+
self.driver = u2_module.connect(connect_arg)
|
|
129
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
130
|
+
self._serial = self.driver.serial
|
|
131
|
+
_write_session(self._serial)
|
|
132
|
+
|
|
133
|
+
info = self.driver.info
|
|
134
|
+
log.info(
|
|
135
|
+
f"✅ [System] Connected to Android device "
|
|
136
|
+
f"({info.get('productName', 'unknown')}, "
|
|
137
|
+
f"SDK: {info.get('sdkInt', '?')}, serial: {self._serial})"
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
log.error(f"❌ [E053] Failed to connect to Android device: {e}")
|
|
141
|
+
raise RuntimeError(f"Android device connection failed: {e}")
|
|
142
|
+
|
|
143
|
+
def teardown(self):
|
|
144
|
+
log.info("⏳ [Teardown] Disconnecting Android device...")
|
|
145
|
+
if self._scrcpy_process:
|
|
146
|
+
self.stop_record_and_get_path("")
|
|
147
|
+
if self.driver:
|
|
148
|
+
try:
|
|
149
|
+
self.driver.service("uiautomator").stop()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
self.driver = None
|
|
153
|
+
log.info("✅ [System] Android device disconnected")
|
|
154
|
+
|
|
155
|
+
def start_record(self, video_name: str):
|
|
156
|
+
log.info("📹 [System] Starting scrcpy recording...")
|
|
157
|
+
try:
|
|
158
|
+
serial = self._serial or (self.driver.serial if self.driver else "")
|
|
159
|
+
if not serial:
|
|
160
|
+
log.warning("⚠️ [Warning] Cannot determine device serial for recording")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
cmd = [
|
|
164
|
+
"scrcpy",
|
|
165
|
+
"-s", serial,
|
|
166
|
+
"--no-playback",
|
|
167
|
+
"--record", video_name,
|
|
168
|
+
"--video-bit-rate", "2M",
|
|
169
|
+
"--max-fps", "30",
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
popen_kwargs = {
|
|
173
|
+
"stdout": subprocess.DEVNULL,
|
|
174
|
+
"stderr": subprocess.DEVNULL,
|
|
175
|
+
}
|
|
176
|
+
if sys.platform == "win32":
|
|
177
|
+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
178
|
+
else:
|
|
179
|
+
popen_kwargs["preexec_fn"] = os.setsid
|
|
180
|
+
|
|
181
|
+
self._scrcpy_process = subprocess.Popen(cmd, **popen_kwargs)
|
|
182
|
+
time.sleep(1.0)
|
|
183
|
+
|
|
184
|
+
if self._scrcpy_process.poll() is not None:
|
|
185
|
+
log.error("❌ [Error] scrcpy crashed on startup")
|
|
186
|
+
self._scrcpy_process = None
|
|
187
|
+
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
log.error(
|
|
190
|
+
"❌ [Error] scrcpy not found in PATH. "
|
|
191
|
+
"Fix: brew install scrcpy (macOS) or see https://github.com/Genymobile/scrcpy"
|
|
192
|
+
)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
log.error(f"❌ [Error] Failed to start scrcpy: {e}")
|
|
195
|
+
|
|
196
|
+
def stop_record_and_get_path(self, video_name: str) -> str:
|
|
197
|
+
if not self._scrcpy_process:
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
log.info("⏳ [System] Stopping scrcpy recording...")
|
|
201
|
+
try:
|
|
202
|
+
if sys.platform == "win32":
|
|
203
|
+
self._scrcpy_process.send_signal(signal.CTRL_BREAK_EVENT)
|
|
204
|
+
else:
|
|
205
|
+
pgid = os.getpgid(self._scrcpy_process.pid)
|
|
206
|
+
os.killpg(pgid, signal.SIGINT)
|
|
207
|
+
self._scrcpy_process.wait(timeout=5.0)
|
|
208
|
+
except subprocess.TimeoutExpired:
|
|
209
|
+
log.warning("[Warning] scrcpy did not exit in time, force killing...")
|
|
210
|
+
try:
|
|
211
|
+
if sys.platform == "win32":
|
|
212
|
+
self._scrcpy_process.kill()
|
|
213
|
+
else:
|
|
214
|
+
pgid = os.getpgid(self._scrcpy_process.pid)
|
|
215
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
216
|
+
except Exception:
|
|
217
|
+
self._scrcpy_process.kill()
|
|
218
|
+
self._scrcpy_process.wait()
|
|
219
|
+
except OSError:
|
|
220
|
+
try:
|
|
221
|
+
self._scrcpy_process.send_signal(signal.SIGINT)
|
|
222
|
+
self._scrcpy_process.wait(timeout=2)
|
|
223
|
+
except Exception:
|
|
224
|
+
self._scrcpy_process.kill()
|
|
225
|
+
self._scrcpy_process.wait()
|
|
226
|
+
except Exception as e:
|
|
227
|
+
log.error(f"❌ [Error] Failed to stop scrcpy: {e}")
|
|
228
|
+
|
|
229
|
+
self._scrcpy_process = None
|
|
230
|
+
return self._validate_video_file(video_name)
|
|
231
|
+
|
|
232
|
+
def take_screenshot(self, _retry: bool = True) -> bytes:
|
|
233
|
+
if not self.driver:
|
|
234
|
+
log.error("❌ [E054] Cannot take screenshot: no device connection")
|
|
235
|
+
return b""
|
|
236
|
+
try:
|
|
237
|
+
image = self.driver.screenshot()
|
|
238
|
+
img_bytes = io.BytesIO()
|
|
239
|
+
image.save(img_bytes, format='PNG')
|
|
240
|
+
return img_bytes.getvalue()
|
|
241
|
+
except Exception as e:
|
|
242
|
+
log.error(f"❌ [E055] Screenshot failed: {e}")
|
|
243
|
+
if _retry and self._attempt_reconnect():
|
|
244
|
+
return self.take_screenshot(_retry=False)
|
|
245
|
+
return b""
|
|
246
|
+
|
|
247
|
+
def _attempt_reconnect(self) -> bool:
|
|
248
|
+
log.info("⚠️ [System] Attempting Android device reconnect...")
|
|
249
|
+
try:
|
|
250
|
+
import uiautomator2 as u2
|
|
251
|
+
if _is_device_online(self._serial):
|
|
252
|
+
connect_arg = self._serial if self._serial else None
|
|
253
|
+
self.driver = u2.connect(connect_arg)
|
|
254
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
255
|
+
log.info("✅ [System] Android device reconnected successfully")
|
|
256
|
+
return True
|
|
257
|
+
except Exception as e:
|
|
258
|
+
log.error(f"❌ [System] Reconnect failed: {e}")
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
def _validate_video_file(self, video_name: str) -> str:
|
|
262
|
+
if not video_name:
|
|
263
|
+
return ""
|
|
264
|
+
if os.path.exists(video_name):
|
|
265
|
+
file_size = os.path.getsize(video_name)
|
|
266
|
+
if file_size < 1024:
|
|
267
|
+
log.warning(f"⚠️ [Warning] Recording file size abnormal: {file_size} bytes")
|
|
268
|
+
else:
|
|
269
|
+
log.info(f"✅ [System] Recording saved ({file_size // 1024} KB)")
|
|
270
|
+
return video_name
|
|
271
|
+
else:
|
|
272
|
+
log.error(f"❌ [Error] Recording file not found: {video_name}")
|
|
273
|
+
return ""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BasePlatformAdapter(ABC):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.driver = None
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def setup(self):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def teardown(self):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def take_screenshot(self) -> bytes:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
def start_record(self, video_name: str):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def stop_record_and_get_path(self, video_name: str) -> str:
|
|
24
|
+
return ""
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import config.config as config
|
|
9
|
+
from common.logs import log
|
|
10
|
+
|
|
11
|
+
from .base_adapter import BasePlatformAdapter
|
|
12
|
+
|
|
13
|
+
_SESSION_FILE = os.path.abspath(os.path.join("report", "ios_session.json"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_session() -> dict | None:
|
|
17
|
+
if not os.path.exists(_SESSION_FILE):
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
import json
|
|
21
|
+
with open(_SESSION_FILE, "r") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_session(wda_url: str, udid: str) -> None:
|
|
28
|
+
import json
|
|
29
|
+
os.makedirs(os.path.dirname(_SESSION_FILE), exist_ok=True)
|
|
30
|
+
with open(_SESSION_FILE, "w") as f:
|
|
31
|
+
json.dump({"wda_url": wda_url, "udid": udid}, f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clear_session() -> None:
|
|
35
|
+
if os.path.exists(_SESSION_FILE):
|
|
36
|
+
os.remove(_SESSION_FILE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_wda_alive(url: str, timeout: float = 3.0) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
import urllib.request
|
|
42
|
+
urllib.request.urlopen(f"{url}/status", timeout=timeout)
|
|
43
|
+
return True
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_macos() -> bool:
|
|
49
|
+
return sys.platform == "darwin"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find_device_udid() -> str:
|
|
53
|
+
if config.IOS_DEVICE_UDID:
|
|
54
|
+
return config.IOS_DEVICE_UDID
|
|
55
|
+
if not _is_macos():
|
|
56
|
+
return ""
|
|
57
|
+
try:
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["xcrun", "simctl", "list", "devices", "booted", "-j"],
|
|
60
|
+
capture_output=True, text=True, timeout=5,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode == 0:
|
|
63
|
+
import json
|
|
64
|
+
data = json.loads(result.stdout)
|
|
65
|
+
for runtime_devices in data.get("devices", {}).values():
|
|
66
|
+
for device in runtime_devices:
|
|
67
|
+
if device.get("state") == "Booted":
|
|
68
|
+
return device.get("udid", "")
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class IosWdaAdapter(BasePlatformAdapter):
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._wda_url = config.WDA_URL
|
|
79
|
+
self._udid = ""
|
|
80
|
+
self._record_process = None
|
|
81
|
+
self._record_path = ""
|
|
82
|
+
|
|
83
|
+
def setup(self):
|
|
84
|
+
log.info("⏳ [Setup] Initializing iOS (WDA) device...")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
import wda
|
|
88
|
+
except ImportError:
|
|
89
|
+
log.error(
|
|
90
|
+
"❌ [E040] facebook-wda not installed. "
|
|
91
|
+
"Fix: pip install screenforge[ios] or pip install facebook-wda"
|
|
92
|
+
)
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"facebook-wda not installed. Run: pip install screenforge[ios]"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if self._try_reconnect(wda):
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
self._connect_fresh(wda)
|
|
101
|
+
|
|
102
|
+
def _try_reconnect(self, wda_module) -> bool:
|
|
103
|
+
session = _read_session()
|
|
104
|
+
if not session:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
url = session.get("wda_url", "")
|
|
108
|
+
if not url or not _is_wda_alive(url):
|
|
109
|
+
log.info("⚠️ [System] Previous WDA session no longer alive, connecting fresh")
|
|
110
|
+
_clear_session()
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self.driver = wda_module.Client(url)
|
|
115
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
116
|
+
status = self.driver.status()
|
|
117
|
+
log.info(
|
|
118
|
+
f"✅ [System] Reconnected to WDA session "
|
|
119
|
+
f"(iOS {status.get('os', {}).get('version', 'unknown')})"
|
|
120
|
+
)
|
|
121
|
+
self._wda_url = url
|
|
122
|
+
self._udid = session.get("udid", "")
|
|
123
|
+
return True
|
|
124
|
+
except Exception as e:
|
|
125
|
+
log.info(f"⚠️ [System] Reconnect failed ({e}), connecting fresh")
|
|
126
|
+
_clear_session()
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def _connect_fresh(self, wda_module):
|
|
130
|
+
if not _is_wda_alive(self._wda_url):
|
|
131
|
+
log.error(
|
|
132
|
+
f"❌ [E041] WebDriverAgent not reachable at {self._wda_url}. "
|
|
133
|
+
"Fix: start WDA on the device first. "
|
|
134
|
+
"See: https://github.com/appium/WebDriverAgent"
|
|
135
|
+
)
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"WebDriverAgent not reachable at {self._wda_url}. "
|
|
138
|
+
"Ensure WDA is running on the target device."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
self.driver = wda_module.Client(self._wda_url)
|
|
143
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
144
|
+
status = self.driver.status()
|
|
145
|
+
self._udid = _find_device_udid()
|
|
146
|
+
_write_session(self._wda_url, self._udid)
|
|
147
|
+
log.info(
|
|
148
|
+
f"✅ [System] Connected to iOS device via WDA "
|
|
149
|
+
f"(iOS {status.get('os', {}).get('version', 'unknown')}, "
|
|
150
|
+
f"UDID: {self._udid or 'auto'})"
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
log.error(f"❌ [E042] Failed to connect to WDA at {self._wda_url}: {e}")
|
|
154
|
+
raise RuntimeError(f"WDA connection failed: {e}")
|
|
155
|
+
|
|
156
|
+
def teardown(self):
|
|
157
|
+
log.info("⏳ [Teardown] Disconnecting iOS device...")
|
|
158
|
+
if self._record_process:
|
|
159
|
+
self.stop_record_and_get_path("")
|
|
160
|
+
if self.driver:
|
|
161
|
+
try:
|
|
162
|
+
self.driver.session().close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
self.driver = None
|
|
166
|
+
log.info("✅ [System] iOS device disconnected")
|
|
167
|
+
|
|
168
|
+
def start_record(self, video_name: str):
|
|
169
|
+
if not _is_macos():
|
|
170
|
+
log.info(
|
|
171
|
+
"⚠️ [System] iOS recording requires macOS with Xcode. "
|
|
172
|
+
"Skipping on this platform."
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
udid = self._udid or _find_device_udid()
|
|
177
|
+
if not udid:
|
|
178
|
+
log.warning(
|
|
179
|
+
"⚠️ [Warning] Cannot determine device UDID for recording. "
|
|
180
|
+
"Fix: export IOS_DEVICE_UDID=<your-udid> or boot a simulator"
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
video_dir = os.path.abspath("report")
|
|
185
|
+
os.makedirs(video_dir, exist_ok=True)
|
|
186
|
+
if not video_name.endswith(".mov"):
|
|
187
|
+
video_name = video_name + ".mov"
|
|
188
|
+
self._record_path = os.path.join(video_dir, video_name)
|
|
189
|
+
|
|
190
|
+
log.info(f"📹 [System] Starting iOS screen recording (UDID: {udid})...")
|
|
191
|
+
try:
|
|
192
|
+
cmd = ["xcrun", "simctl", "io", udid, "recordVideo", self._record_path]
|
|
193
|
+
self._record_process = subprocess.Popen(
|
|
194
|
+
cmd,
|
|
195
|
+
stdout=subprocess.DEVNULL,
|
|
196
|
+
stderr=subprocess.PIPE,
|
|
197
|
+
preexec_fn=os.setsid,
|
|
198
|
+
)
|
|
199
|
+
time.sleep(1.0)
|
|
200
|
+
if self._record_process.poll() is not None:
|
|
201
|
+
stderr = self._record_process.stderr.read().decode() if self._record_process.stderr else ""
|
|
202
|
+
log.error(f"❌ [Error] xcrun simctl recordVideo crashed on startup: {stderr}")
|
|
203
|
+
self._record_process = None
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
log.error(
|
|
206
|
+
"❌ [Error] xcrun not found. "
|
|
207
|
+
"Fix: install Xcode Command Line Tools (xcode-select --install)"
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
log.error(f"❌ [Error] Failed to start iOS recording: {e}")
|
|
211
|
+
|
|
212
|
+
def stop_record_and_get_path(self, video_name: str = "") -> str:
|
|
213
|
+
if not self._record_process:
|
|
214
|
+
return ""
|
|
215
|
+
|
|
216
|
+
log.info("⏳ [System] Stopping iOS recording...")
|
|
217
|
+
try:
|
|
218
|
+
pgid = os.getpgid(self._record_process.pid)
|
|
219
|
+
os.killpg(pgid, signal.SIGINT)
|
|
220
|
+
self._record_process.wait(timeout=5.0)
|
|
221
|
+
except subprocess.TimeoutExpired:
|
|
222
|
+
log.warning("[Warning] Recording did not stop in time, force killing...")
|
|
223
|
+
try:
|
|
224
|
+
pgid = os.getpgid(self._record_process.pid)
|
|
225
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
226
|
+
except Exception:
|
|
227
|
+
self._record_process.kill()
|
|
228
|
+
self._record_process.wait()
|
|
229
|
+
except OSError:
|
|
230
|
+
try:
|
|
231
|
+
self._record_process.send_signal(signal.SIGINT)
|
|
232
|
+
self._record_process.wait(timeout=2)
|
|
233
|
+
except Exception:
|
|
234
|
+
self._record_process.kill()
|
|
235
|
+
self._record_process.wait()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
log.error(f"❌ [Error] Failed to stop recording: {e}")
|
|
238
|
+
|
|
239
|
+
self._record_process = None
|
|
240
|
+
|
|
241
|
+
actual_path = getattr(self, "_record_path", "")
|
|
242
|
+
if actual_path and os.path.exists(actual_path):
|
|
243
|
+
file_size = os.path.getsize(actual_path)
|
|
244
|
+
if file_size < 1024:
|
|
245
|
+
log.warning(f"⚠️ [Warning] Recording file size abnormal: {file_size} bytes")
|
|
246
|
+
else:
|
|
247
|
+
log.info(f"✅ [System] iOS recording saved ({file_size // 1024} KB)")
|
|
248
|
+
return actual_path
|
|
249
|
+
|
|
250
|
+
return ""
|
|
251
|
+
|
|
252
|
+
def take_screenshot(self, _retry: bool = True) -> bytes:
|
|
253
|
+
if not self.driver:
|
|
254
|
+
log.error("❌ [E043] Cannot take screenshot: no WDA connection")
|
|
255
|
+
return b""
|
|
256
|
+
try:
|
|
257
|
+
image = self.driver.screenshot()
|
|
258
|
+
img_bytes = io.BytesIO()
|
|
259
|
+
image.save(img_bytes, format='PNG')
|
|
260
|
+
return img_bytes.getvalue()
|
|
261
|
+
except Exception as e:
|
|
262
|
+
log.error(f"❌ [E044] Screenshot failed: {e}")
|
|
263
|
+
if _retry and self._attempt_reconnect():
|
|
264
|
+
return self.take_screenshot(_retry=False)
|
|
265
|
+
return b""
|
|
266
|
+
|
|
267
|
+
def _attempt_reconnect(self) -> bool:
|
|
268
|
+
log.info("⚠️ [System] Attempting WDA reconnect...")
|
|
269
|
+
try:
|
|
270
|
+
import wda
|
|
271
|
+
if _is_wda_alive(self._wda_url):
|
|
272
|
+
self.driver = wda.Client(self._wda_url)
|
|
273
|
+
self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
|
|
274
|
+
log.info("✅ [System] WDA reconnected successfully")
|
|
275
|
+
return True
|
|
276
|
+
except Exception as e:
|
|
277
|
+
log.error(f"❌ [System] Reconnect failed: {e}")
|
|
278
|
+
return False
|