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,271 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import config.config as config
|
|
8
|
+
from common.logs import log
|
|
9
|
+
|
|
10
|
+
from .base_adapter import BasePlatformAdapter
|
|
11
|
+
|
|
12
|
+
_SESSION_FILE = os.path.abspath(os.path.join("report", "web_session.json"))
|
|
13
|
+
_CDP_PORT = 9333
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_session() -> dict | None:
|
|
17
|
+
if not os.path.exists(_SESSION_FILE):
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
with open(_SESSION_FILE, "r") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_session(cdp_url: str, pid: int) -> None:
|
|
27
|
+
os.makedirs(os.path.dirname(_SESSION_FILE), exist_ok=True)
|
|
28
|
+
with open(_SESSION_FILE, "w") as f:
|
|
29
|
+
json.dump({"cdp_url": cdp_url, "pid": pid}, f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clear_session() -> None:
|
|
33
|
+
if os.path.exists(_SESSION_FILE):
|
|
34
|
+
os.remove(_SESSION_FILE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_process_alive(pid: int) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
os.kill(pid, 0)
|
|
40
|
+
except (OSError, ProcessLookupError):
|
|
41
|
+
return False
|
|
42
|
+
# os.kill(pid, 0) succeeds for a ZOMBIE (killed but not yet reaped by its
|
|
43
|
+
# parent — common here because Chromium's parent is Playwright's node driver,
|
|
44
|
+
# which only reaps on its own exit). A zombie is dead, not alive. On POSIX,
|
|
45
|
+
# check the process state and treat Z/defunct as not-alive so the reaper and
|
|
46
|
+
# the reconnect path don't mistake a corpse for a live browser.
|
|
47
|
+
if sys.platform != "win32":
|
|
48
|
+
try:
|
|
49
|
+
state = subprocess.run(
|
|
50
|
+
["ps", "-o", "state=", "-p", str(pid)],
|
|
51
|
+
capture_output=True, text=True, timeout=3,
|
|
52
|
+
).stdout.strip()
|
|
53
|
+
if state.startswith("Z"):
|
|
54
|
+
return False
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # ps unavailable → fall back to the os.kill result
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stop_persistent_browser() -> bool:
|
|
61
|
+
"""Terminate the detached persistent Chromium recorded in the session file.
|
|
62
|
+
|
|
63
|
+
The web adapter launches Chromium with --remote-debugging-port and keeps it
|
|
64
|
+
running across CLI calls (teardown only disconnects). Nothing else ever kills
|
|
65
|
+
it, so repeated runs leak browsers holding port 9333. This is the explicit
|
|
66
|
+
reaper, wired to `--web-stop`. Returns True if a live process was signalled.
|
|
67
|
+
"""
|
|
68
|
+
import signal
|
|
69
|
+
|
|
70
|
+
session = _read_session()
|
|
71
|
+
if not session:
|
|
72
|
+
log.info("ℹ️ [System] No persistent web browser session on record")
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
pid = session.get("pid", 0)
|
|
76
|
+
if not pid or not _is_process_alive(pid):
|
|
77
|
+
log.info("ℹ️ [System] Recorded browser is not running; clearing stale session")
|
|
78
|
+
_clear_session()
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
import subprocess as _sp
|
|
84
|
+
_sp.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
|
|
85
|
+
else:
|
|
86
|
+
# Chromium ignores SIGTERM while a CDP client is attached, so escalate
|
|
87
|
+
# to SIGKILL if it's still up after a short grace period. (A leftover
|
|
88
|
+
# zombie — parent not yet reaped — reads as not-alive via _is_process_alive.)
|
|
89
|
+
os.kill(pid, signal.SIGTERM)
|
|
90
|
+
for _ in range(10):
|
|
91
|
+
time.sleep(0.2)
|
|
92
|
+
if not _is_process_alive(pid):
|
|
93
|
+
break
|
|
94
|
+
else:
|
|
95
|
+
try:
|
|
96
|
+
os.kill(pid, signal.SIGKILL)
|
|
97
|
+
except ProcessLookupError:
|
|
98
|
+
pass
|
|
99
|
+
log.info(f"✅ [System] Stopped persistent Chromium (pid {pid})")
|
|
100
|
+
_clear_session()
|
|
101
|
+
return True
|
|
102
|
+
except Exception as e:
|
|
103
|
+
log.error(f"❌ [Error] Failed to stop persistent browser (pid {pid}): {e}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WebPlaywrightAdapter(BasePlatformAdapter):
|
|
108
|
+
|
|
109
|
+
def __init__(self):
|
|
110
|
+
super().__init__()
|
|
111
|
+
self.playwright = None
|
|
112
|
+
self.browser = None
|
|
113
|
+
self.context = None
|
|
114
|
+
self.driver = None
|
|
115
|
+
self._chromium_process = None
|
|
116
|
+
|
|
117
|
+
self.state_file = os.path.abspath(os.path.join("report", "browser_state.json"))
|
|
118
|
+
self.viewport_size = {"width": 1920, "height": 1080}
|
|
119
|
+
|
|
120
|
+
def setup(self):
|
|
121
|
+
log.info("⏱️ [System] Initializing Web (Playwright) browser...")
|
|
122
|
+
try:
|
|
123
|
+
from playwright.sync_api import sync_playwright
|
|
124
|
+
except ImportError:
|
|
125
|
+
log.error("❌ [Error] playwright not installed. Run: pip install playwright && playwright install")
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
self.playwright = sync_playwright().start()
|
|
129
|
+
|
|
130
|
+
if self._try_reconnect():
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
self._launch_persistent_browser()
|
|
134
|
+
|
|
135
|
+
def _try_reconnect(self) -> bool:
|
|
136
|
+
session = _read_session()
|
|
137
|
+
if not session:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
cdp_url = session.get("cdp_url", "")
|
|
141
|
+
pid = session.get("pid", 0)
|
|
142
|
+
|
|
143
|
+
if not cdp_url or not _is_process_alive(pid):
|
|
144
|
+
log.info("⚠️ [System] Persistent browser no longer exists, launching new one")
|
|
145
|
+
_clear_session()
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
self.browser = self.playwright.chromium.connect_over_cdp(cdp_url)
|
|
150
|
+
log.info("✅ [System] Reconnected to persistent browser session")
|
|
151
|
+
|
|
152
|
+
if self.browser.contexts:
|
|
153
|
+
self.context = self.browser.contexts[0]
|
|
154
|
+
if self.context.pages:
|
|
155
|
+
self.driver = self.context.pages[0]
|
|
156
|
+
self.driver.set_default_timeout(config.DEFAULT_TIMEOUT * 1000)
|
|
157
|
+
log.info(f"✅ [System] Reusing existing page: {self.driver.url}")
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
self._create_context_and_page()
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
log.info(f"⚠️ [System] Reconnect failed ({e}), launching new browser")
|
|
165
|
+
_clear_session()
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def _launch_persistent_browser(self):
|
|
169
|
+
chromium_path = self.playwright.chromium.executable_path
|
|
170
|
+
if not chromium_path or not os.path.exists(chromium_path):
|
|
171
|
+
log.error("❌ [Error] Playwright Chromium not found. Run: playwright install chromium")
|
|
172
|
+
raise RuntimeError("Playwright Chromium not found")
|
|
173
|
+
|
|
174
|
+
cdp_url = f"http://127.0.0.1:{_CDP_PORT}"
|
|
175
|
+
user_data_dir = os.path.abspath(os.path.join("report", "chromium_profile"))
|
|
176
|
+
os.makedirs(user_data_dir, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
log.info(f"🚀 [System] Launching persistent Chromium (CDP: {cdp_url})...")
|
|
179
|
+
self._chromium_process = subprocess.Popen(
|
|
180
|
+
[
|
|
181
|
+
chromium_path,
|
|
182
|
+
f"--remote-debugging-port={_CDP_PORT}",
|
|
183
|
+
f"--user-data-dir={user_data_dir}",
|
|
184
|
+
"--no-first-run",
|
|
185
|
+
"--no-default-browser-check",
|
|
186
|
+
f"--window-size={self.viewport_size['width']},{self.viewport_size['height']}",
|
|
187
|
+
],
|
|
188
|
+
stdout=subprocess.DEVNULL,
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
for _ in range(30):
|
|
193
|
+
try:
|
|
194
|
+
import urllib.request
|
|
195
|
+
urllib.request.urlopen(f"{cdp_url}/json/version", timeout=1)
|
|
196
|
+
break
|
|
197
|
+
except Exception:
|
|
198
|
+
time.sleep(0.5)
|
|
199
|
+
else:
|
|
200
|
+
raise RuntimeError(f"Chromium CDP port {_CDP_PORT} startup timed out")
|
|
201
|
+
|
|
202
|
+
_write_session(cdp_url, self._chromium_process.pid)
|
|
203
|
+
|
|
204
|
+
self.browser = self.playwright.chromium.connect_over_cdp(cdp_url)
|
|
205
|
+
log.info("✅ [System] Persistent Chromium launched and connected")
|
|
206
|
+
|
|
207
|
+
if self.browser.contexts and self.browser.contexts[0].pages:
|
|
208
|
+
self.context = self.browser.contexts[0]
|
|
209
|
+
self.driver = self.context.pages[0]
|
|
210
|
+
self.driver.set_default_timeout(config.DEFAULT_TIMEOUT * 1000)
|
|
211
|
+
else:
|
|
212
|
+
self._create_context_and_page()
|
|
213
|
+
|
|
214
|
+
def _create_context_and_page(self):
|
|
215
|
+
# NOTE: no video recording here. The adapter attaches to Chromium over CDP
|
|
216
|
+
# (connect_over_cdp) for cross-call session reuse, and Playwright cannot
|
|
217
|
+
# record video for a CDP-attached browser — `page.video` yields an object
|
|
218
|
+
# but no file is ever written. Recording only works for a browser
|
|
219
|
+
# Playwright launches itself, which this design does not do. Web video is
|
|
220
|
+
# therefore unsupported; see stop_record_and_get_path().
|
|
221
|
+
if self.browser.contexts:
|
|
222
|
+
self.context = self.browser.contexts[0]
|
|
223
|
+
else:
|
|
224
|
+
self.context = self.browser.new_context(viewport=self.viewport_size)
|
|
225
|
+
|
|
226
|
+
if os.path.exists(self.state_file):
|
|
227
|
+
try:
|
|
228
|
+
import json as _json
|
|
229
|
+
with open(self.state_file, "r") as f:
|
|
230
|
+
state = _json.load(f)
|
|
231
|
+
for cookie in state.get("cookies", []):
|
|
232
|
+
self.context.add_cookies([cookie])
|
|
233
|
+
log.info(f"✅ [System] Restored browser state from: {self.state_file}")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
log.warning(f"⚠️ [Warning] Failed to restore browser state: {e}")
|
|
236
|
+
|
|
237
|
+
self.driver = self.context.new_page()
|
|
238
|
+
self.driver.set_default_timeout(config.DEFAULT_TIMEOUT * 1000)
|
|
239
|
+
|
|
240
|
+
def teardown(self):
|
|
241
|
+
log.info("⏱️ [System] Saving state and disconnecting (browser keeps running)...")
|
|
242
|
+
try:
|
|
243
|
+
if self.context:
|
|
244
|
+
try:
|
|
245
|
+
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
|
|
246
|
+
self.context.storage_state(path=self.state_file)
|
|
247
|
+
log.info(f"✅ [System] Browser state saved to: {self.state_file}")
|
|
248
|
+
except Exception as e:
|
|
249
|
+
log.warning(f"⚠️ [Warning] Failed to save browser state: {e}")
|
|
250
|
+
finally:
|
|
251
|
+
if self.playwright:
|
|
252
|
+
try:
|
|
253
|
+
self.playwright.stop()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def start_record(self, video_name: str):
|
|
258
|
+
# Web video recording is unsupported (CDP-attached browser; see
|
|
259
|
+
# _create_context_and_page). No-op kept for the adapter interface.
|
|
260
|
+
log.info("ℹ️ [System] Web video recording is not supported (CDP session)")
|
|
261
|
+
|
|
262
|
+
def stop_record_and_get_path(self, video_name: str) -> str:
|
|
263
|
+
# Unsupported on web — return "" cleanly. (This also avoids the old
|
|
264
|
+
# AttributeError: stop used to call self.driver.video.path() which is
|
|
265
|
+
# None when no record_video_dir was set.)
|
|
266
|
+
return ""
|
|
267
|
+
|
|
268
|
+
def take_screenshot(self) -> bytes:
|
|
269
|
+
if self.driver:
|
|
270
|
+
return self.driver.screenshot()
|
|
271
|
+
return b""
|
common/ai.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
|
|
6
|
+
import config.config as config
|
|
7
|
+
from common.cache import CacheManager
|
|
8
|
+
from common.logs import log
|
|
9
|
+
from common.progress import ai_status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AIBrain:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
# 实例化文本专属客户端
|
|
15
|
+
self.text_client = OpenAI(
|
|
16
|
+
api_key=config.OPENAI_API_KEY, base_url=config.OPENAI_BASE_URL
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# 实例化视觉专属客户端
|
|
20
|
+
self.vision_client = OpenAI(
|
|
21
|
+
api_key=config.VISION_API_KEY, base_url=config.VISION_BASE_URL
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.cache_manager = CacheManager(
|
|
25
|
+
cache_dir=config.CACHE_DIR,
|
|
26
|
+
enabled=config.CACHE_ENABLED,
|
|
27
|
+
ttl_days=config.CACHE_TTL_DAYS,
|
|
28
|
+
max_size_mb=config.CACHE_MAX_SIZE_MB,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _verify_locator_in_ui(self, decision: dict, ui_dict: dict) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
校验缓存中推荐的动作,其元素是否真实存在于当前的 UI 树中
|
|
34
|
+
"""
|
|
35
|
+
loc_type = decision.get("locator_type")
|
|
36
|
+
loc_val = decision.get("locator_value")
|
|
37
|
+
|
|
38
|
+
# 如果动作不需要特定元素 (比如 answer 或某些全局操作),直接放行
|
|
39
|
+
if (
|
|
40
|
+
not loc_type
|
|
41
|
+
or not loc_val
|
|
42
|
+
or loc_type not in ["text", "description", "resourceId", "id"]
|
|
43
|
+
):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
elements = ui_dict.get("ui_elements", [])
|
|
47
|
+
for el in elements:
|
|
48
|
+
if loc_type == "text" and el.get("text") == loc_val:
|
|
49
|
+
return True
|
|
50
|
+
if loc_type == "description" and el.get("desc") == loc_val:
|
|
51
|
+
return True
|
|
52
|
+
if loc_type in ["resourceId", "id"] and el.get("id") == loc_val:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def get_action(
|
|
58
|
+
self,
|
|
59
|
+
instruction: str,
|
|
60
|
+
ui_json: str,
|
|
61
|
+
platform: str = "android",
|
|
62
|
+
screenshot_base64: str = None,
|
|
63
|
+
chat_history: list = None,
|
|
64
|
+
skip_cache: bool = False
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""
|
|
67
|
+
向大模型发送指令并返回结构化动作 JSON。
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
ui_dict = json.loads(ui_json)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
ui_dict = {}
|
|
73
|
+
|
|
74
|
+
# ==========================================
|
|
75
|
+
# 1. 缓存读取与物理校验阶段
|
|
76
|
+
# ==========================================
|
|
77
|
+
if not skip_cache:
|
|
78
|
+
cached_l1 = self.cache_manager.get(instruction, ui_dict, platform)
|
|
79
|
+
if cached_l1 is not None:
|
|
80
|
+
log.info("[Cache] L1 exact hit (page-level action cache)")
|
|
81
|
+
return cached_l1
|
|
82
|
+
|
|
83
|
+
if hasattr(self.cache_manager, "get_chat_simple"):
|
|
84
|
+
cached_l2 = self.cache_manager.get_chat_simple(instruction, platform)
|
|
85
|
+
if cached_l2 is not None:
|
|
86
|
+
if self._verify_locator_in_ui(cached_l2, ui_dict):
|
|
87
|
+
log.info("[Cache] L2 semantic hit (global semantic cache)")
|
|
88
|
+
return cached_l2
|
|
89
|
+
else:
|
|
90
|
+
log.warning("[Cache] Semantic hit discarded — target element not present on current page")
|
|
91
|
+
|
|
92
|
+
log.info("[Cache Miss] No cache hit, calling LLM API...")
|
|
93
|
+
else:
|
|
94
|
+
log.info("[System] Cache bypassed, forcing LLM re-evaluation...")
|
|
95
|
+
# ==========================================
|
|
96
|
+
# 2. 处理上下文历史 (触发大模型 Prompt Caching)
|
|
97
|
+
# ==========================================
|
|
98
|
+
history_prompt = ""
|
|
99
|
+
if chat_history:
|
|
100
|
+
# 仅提取历史意图和动作,丢弃庞大的历史 UI 树以防止 Context 爆炸
|
|
101
|
+
history_str = "\n".join(
|
|
102
|
+
[
|
|
103
|
+
f"- 历史步骤{i + 1}: {step.get('action_description')}"
|
|
104
|
+
for i, step in enumerate(chat_history)
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
history_prompt = (f"\n\n【前置对话上下文】(请结合上下文理解当前指令):\n{history_str}")
|
|
108
|
+
|
|
109
|
+
# ==========================================
|
|
110
|
+
# 3. 提示词与 Payload 组装
|
|
111
|
+
# ==========================================
|
|
112
|
+
vision_prompt = ""
|
|
113
|
+
if screenshot_base64:
|
|
114
|
+
vision_prompt = """
|
|
115
|
+
你同时收到了一张真实屏幕截图。请优先结合视觉画面判断页面结构和元素状态!如果 XML 树找不到或者混乱,以视觉为准。
|
|
116
|
+
"""
|
|
117
|
+
system_prompt = f"""
|
|
118
|
+
# Role: {platform} 自动化测试策略生成专家
|
|
119
|
+
|
|
120
|
+
## Profile
|
|
121
|
+
- language: 中文
|
|
122
|
+
- description: 资深自动化测试专家,专门分析UI元素树和视觉画面,将自然语言测试指令转化为可执行的自动化操作策略
|
|
123
|
+
- background: 拥有10年以上UI自动化测试经验,精通各种测试框架和元素定位技术,擅长处理动态UI和复杂交互场景
|
|
124
|
+
- personality: 严谨、细致、逻辑性强,注重测试的准确性和可重复性
|
|
125
|
+
- expertise: UI元素分析、测试策略制定、定位器选择优化、跨平台测试适配
|
|
126
|
+
- target_audience: 测试工程师、开发人员、质量保证团队
|
|
127
|
+
|
|
128
|
+
## Skills
|
|
129
|
+
|
|
130
|
+
1. 元素分析技能
|
|
131
|
+
- UI结构解析: 能够深度解析XML/JSON格式的UI元素树,理解页面层级结构
|
|
132
|
+
- 视觉辅助判断: 结合屏幕截图验证元素状态和布局,解决元素树不准确的问题
|
|
133
|
+
- 动态元素识别: 识别并处理动态生成的CSS、resourceId等不稳定定位器
|
|
134
|
+
- 元素属性评估: 分析元素的text、description、resourceId等关键属性
|
|
135
|
+
|
|
136
|
+
2. 测试策略制定技能
|
|
137
|
+
- 指令解析: 准确理解用户的自然语言测试指令,转化为具体操作步骤
|
|
138
|
+
- 定位器选择: 根据优先级规则选择最稳定可靠的元素定位方式
|
|
139
|
+
- 操作映射: 将测试需求映射到具体的自动化操作类型
|
|
140
|
+
- 异常处理: 预判可能出现的测试异常并提供应对策略
|
|
141
|
+
|
|
142
|
+
## Rules (核心原则)
|
|
143
|
+
|
|
144
|
+
1. 基本原则:
|
|
145
|
+
- 视觉优先原则: 当XML元素树与视觉画面不一致时,以视觉画面为准进行判断
|
|
146
|
+
- 稳定性优先: 选择定位器时,稳定性比简洁性更重要,避免使用动态生成的定位器
|
|
147
|
+
- 准确性保证: 确保生成的测试策略能够准确执行用户的测试意图
|
|
148
|
+
- 完整性要求: 输出必须包含所有必要的操作参数,确保测试可执行
|
|
149
|
+
|
|
150
|
+
2. 行为准则:
|
|
151
|
+
- 严格遵循定位器优先级: css > resourceId > text > description
|
|
152
|
+
- 动态检测机制: 自动检测并规避动态生成的css和resourceId
|
|
153
|
+
- 上下文感知: 结合页面整体结构理解元素关系和状态
|
|
154
|
+
- 验证机制: 对选择的定位器进行逻辑验证,确保唯一性和可访问性
|
|
155
|
+
|
|
156
|
+
## 📋 执行协议 (Protocol)
|
|
157
|
+
{vision_prompt}
|
|
158
|
+
|
|
159
|
+
### 允许的 action 类型:
|
|
160
|
+
- "click": 点击元素
|
|
161
|
+
- "long_click": 长按元素
|
|
162
|
+
- "hover": 悬停元素 (针对 Web 端,触发下拉菜单显示等交互)
|
|
163
|
+
- "input": 在输入框中输入内容 (必须在 extra_value 字段提供输入内容)
|
|
164
|
+
- "swipe": 滑动屏幕以寻找不在视口内的元素。必须在 extra_value 填入 "up", "down", "left" 或 "right"。此时 locator_type 填 "global"。
|
|
165
|
+
- "press": 模拟键盘或物理系统按键。必须在 extra_value 填入按键名 (如 "Enter", "Back", "Home")。此时 locator_type 填 "global"。
|
|
166
|
+
- "scroll_into_view": (仅 Web) 将指定元素滚动到视口内 (元素级,优于盲目 swipe)。
|
|
167
|
+
- "select": (仅 Web) 在原生 <select> 下拉框中选择选项。extra_value 填选项的可见文本或 value。
|
|
168
|
+
- "upload": (仅 Web) 给文件 <input> 设置文件。extra_value 填文件路径。
|
|
169
|
+
- "double_click": (仅 Web) 双击元素。
|
|
170
|
+
- "right_click": (仅 Web) 右键点击元素 (触发上下文菜单)。
|
|
171
|
+
- "drag": (仅 Web) 将源元素拖拽到目标。locator 定位源元素,extra_value 填目标 (css 选择器或可见文本)。
|
|
172
|
+
- "wait_for": 显式等待元素出现或消失 (替代死等)。extra_value 填 "visible"(默认) 或 "hidden"。用于等待异步加载完成。
|
|
173
|
+
- "assert_exist": 校验某个元素是否在页面上出现
|
|
174
|
+
- "assert_not_exist": 校验某个元素已消失/不存在 (如加载动画消失、弹窗关闭)
|
|
175
|
+
- "assert_text_equals": 校验某个元素的文本是否与期望值【完全相等】
|
|
176
|
+
- "assert_text_contains": 校验某个元素的文本【包含】指定子串 (动态文本首选,比完全相等更稳健)。在 extra_value 填子串。
|
|
177
|
+
- "assert_value": 校验输入框/表单字段的当前值等于期望值。在 extra_value 填期望值。
|
|
178
|
+
- "assert_url": (仅 Web) 校验当前页面 URL 包含指定子串。locator_type/value 填 "global",extra_value 填 URL 子串 (如 "/dashboard")。
|
|
179
|
+
- "not_found": 如果在提供的 UI 树中完全找不到符合用户意图的元素,且必须通过视觉验证,请务必返回此 action!
|
|
180
|
+
|
|
181
|
+
### 定位器选择铁律
|
|
182
|
+
1. 优先级顺序:css > resourceId > text > description
|
|
183
|
+
2. 【🚨 降级原则】当发现 css 或 resourceId 是动态生成的(包含随机hash、时间戳),请严格降级并优先选择 "text" 或 "description"!
|
|
184
|
+
|
|
185
|
+
### 强制输出格式
|
|
186
|
+
必须输出纯 JSON 对象,不要包含任何 markdown 格式,包含顶级 key "result",内部结构如下:
|
|
187
|
+
{{"result": {{"action": "...", "locator_type": "...", "locator_value": "...", "extra_value": "..."}}}}
|
|
188
|
+
|
|
189
|
+
## Workflows
|
|
190
|
+
|
|
191
|
+
- 目标: 将用户的测试指令转化为可执行的自动化测试策略
|
|
192
|
+
- 步骤 1: 接收并分析UI元素树(JSON格式),同时检查是否有视觉辅助截图
|
|
193
|
+
- 步骤 2: 解析用户的自然语言测试指令,明确测试意图和期望结果
|
|
194
|
+
- 步骤 3: 结合元素树和视觉画面,确定目标元素及其状态
|
|
195
|
+
- 步骤 4: 根据定位器优先级规则,选择最稳定可靠的定位器
|
|
196
|
+
- 步骤 5: 将测试指令映射到具体的操作类型,并准备必要参数
|
|
197
|
+
- 步骤 6: 按照指定格式输出JSON格式的测试策略
|
|
198
|
+
- 预期结果: 生成一个完整、准确、可执行的自动化测试操作策略
|
|
199
|
+
|
|
200
|
+
## Initialization
|
|
201
|
+
作为自动化测试策略生成专家,你必须遵守上述Rules,严格按照【执行协议】输出结果。
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
user_prompt = f"用户指令: {instruction}{history_prompt}\n当前屏幕 UI 树:\n{ui_json}"
|
|
205
|
+
user_message_content = [{"type": "text", "text": user_prompt}]
|
|
206
|
+
|
|
207
|
+
if screenshot_base64:
|
|
208
|
+
user_message_content.append(
|
|
209
|
+
{
|
|
210
|
+
"type": "image_url",
|
|
211
|
+
"image_url": {"url": f"data:image/png;base64,{screenshot_base64}"},
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# ==========================================
|
|
216
|
+
# 3. 动态智能路由
|
|
217
|
+
# ==========================================
|
|
218
|
+
if screenshot_base64:
|
|
219
|
+
active_client = self.vision_client
|
|
220
|
+
active_model = config.VISION_MODEL_NAME
|
|
221
|
+
log.info(f"[AI] Using multimodal vision model: {active_model}")
|
|
222
|
+
else:
|
|
223
|
+
active_client = self.text_client
|
|
224
|
+
active_model = config.MODEL_NAME
|
|
225
|
+
|
|
226
|
+
start_time = time.time()
|
|
227
|
+
decision = self._call_llm(active_client, active_model, system_prompt, user_message_content)
|
|
228
|
+
llm_latency = time.time() - start_time
|
|
229
|
+
log.info(f"[AI] LLM response received in {llm_latency:.2f}s")
|
|
230
|
+
|
|
231
|
+
# ==========================================
|
|
232
|
+
# 4. 缓存全量回写阶段
|
|
233
|
+
# ==========================================
|
|
234
|
+
if decision:
|
|
235
|
+
# 1. 只要成功,必然写入强绑定的页面缓存 (L1)
|
|
236
|
+
self.cache_manager.set(instruction, ui_dict, decision, platform, llm_latency=llm_latency)
|
|
237
|
+
|
|
238
|
+
# 2. 放开 L2 写入
|
|
239
|
+
if hasattr(self.cache_manager, "set_chat_simple"):
|
|
240
|
+
self.cache_manager.set_chat_simple(instruction, decision, platform, llm_latency=llm_latency)
|
|
241
|
+
|
|
242
|
+
return decision
|
|
243
|
+
|
|
244
|
+
def _call_llm(
|
|
245
|
+
self,
|
|
246
|
+
client: OpenAI,
|
|
247
|
+
model_name: str,
|
|
248
|
+
system_prompt: str,
|
|
249
|
+
user_message_content: list,
|
|
250
|
+
) -> dict:
|
|
251
|
+
"""
|
|
252
|
+
封装底层的 LLM 网络调用
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
with ai_status(f"Thinking ({model_name})..."):
|
|
256
|
+
response = client.chat.completions.create(
|
|
257
|
+
model=model_name,
|
|
258
|
+
messages=[
|
|
259
|
+
{"role": "system", "content": system_prompt},
|
|
260
|
+
{"role": "user", "content": user_message_content},
|
|
261
|
+
],
|
|
262
|
+
temperature=0.1,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
result_text = response.choices[0].message.content.strip()
|
|
266
|
+
|
|
267
|
+
if "```json" in result_text:
|
|
268
|
+
result_text = result_text.split("```json")[1].split("```")[0].strip()
|
|
269
|
+
elif "```" in result_text:
|
|
270
|
+
result_text = result_text.replace("```", "").strip()
|
|
271
|
+
|
|
272
|
+
parsed_json = json.loads(result_text)
|
|
273
|
+
return parsed_json.get("result", {})
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
log.error(f"[Error] Model ({model_name}) request or parse failed: {e}")
|
|
277
|
+
return {}
|