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
cli/shared.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Shared lazy proxies, adapter factories, and utility functions."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from common.capabilities import (
|
|
7
|
+
ACTIONS_REQUIRING_EXTRA_VALUE,
|
|
8
|
+
GLOBAL_ACTIONS,
|
|
9
|
+
SUPPORTED_ACTIONS,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _LazyProxy:
|
|
14
|
+
def __init__(self, loader):
|
|
15
|
+
object.__setattr__(self, "_loader", loader)
|
|
16
|
+
object.__setattr__(self, "_value", None)
|
|
17
|
+
|
|
18
|
+
def _load(self):
|
|
19
|
+
value = object.__getattribute__(self, "_value")
|
|
20
|
+
if value is None:
|
|
21
|
+
value = object.__getattribute__(self, "_loader")()
|
|
22
|
+
object.__setattr__(self, "_value", value)
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name):
|
|
26
|
+
return getattr(self._load(), name)
|
|
27
|
+
|
|
28
|
+
def __setattr__(self, name, value):
|
|
29
|
+
if name in {"_loader", "_value"}:
|
|
30
|
+
object.__setattr__(self, name, value)
|
|
31
|
+
return
|
|
32
|
+
setattr(self._load(), name, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_config_module():
|
|
36
|
+
import config.config as _config
|
|
37
|
+
|
|
38
|
+
return _config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_log_object():
|
|
42
|
+
from common.logs import log as _log
|
|
43
|
+
|
|
44
|
+
return _log
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
config = _LazyProxy(_load_config_module)
|
|
48
|
+
log = _LazyProxy(_load_log_object)
|
|
49
|
+
UIExecutor = None
|
|
50
|
+
get_actual_element = None
|
|
51
|
+
StepHistoryManager = None
|
|
52
|
+
run_preflight = None
|
|
53
|
+
RunReporter = None
|
|
54
|
+
compress_web_dom = None
|
|
55
|
+
compress_android_xml = None
|
|
56
|
+
compress_ios_xml = None
|
|
57
|
+
AndroidU2Adapter = None
|
|
58
|
+
IosWdaAdapter = None
|
|
59
|
+
WebPlaywrightAdapter = None
|
|
60
|
+
AutonomousBrain = None
|
|
61
|
+
load_workflow_file = None
|
|
62
|
+
WorkflowLoadError = None
|
|
63
|
+
parse_workflow_var_overrides = None
|
|
64
|
+
resolve_workflow_definition = None
|
|
65
|
+
SUPPORTED_INLINE_ACTIONS = SUPPORTED_ACTIONS
|
|
66
|
+
GLOBAL_INLINE_ACTIONS = GLOBAL_ACTIONS
|
|
67
|
+
INLINE_ACTIONS_REQUIRING_EXTRA_VALUE = ACTIONS_REQUIRING_EXTRA_VALUE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _slug_to_test_name(label: str) -> str:
|
|
71
|
+
"""Turn a human goal/action label into a valid pytest function name.
|
|
72
|
+
|
|
73
|
+
Keeps unicode word chars (PEP 3131 allows unicode identifiers, so Chinese
|
|
74
|
+
goals survive instead of being stripped to nothing); collapses everything
|
|
75
|
+
else to single underscores. Always prefixed with `test_` so pytest collects
|
|
76
|
+
it, and guarded so a symbols-only label can't yield a bare `test_`.
|
|
77
|
+
"""
|
|
78
|
+
import re
|
|
79
|
+
import unicodedata
|
|
80
|
+
|
|
81
|
+
# Lowercase ASCII for conventional pytest names; unicode word chars (e.g.
|
|
82
|
+
# Chinese) pass through .lower() unchanged and stay as-is. NFKC folds
|
|
83
|
+
# compatibility chars so "½"/"²" don't survive as non-identifier \w chars.
|
|
84
|
+
normalized = unicodedata.normalize("NFKC", str(label).strip().lower())
|
|
85
|
+
slug = re.sub(r"\W+", "_", normalized, flags=re.UNICODE).strip("_")
|
|
86
|
+
if not slug:
|
|
87
|
+
return "test_auto_generated_case"
|
|
88
|
+
# A leading digit is a valid word char but not a valid identifier start.
|
|
89
|
+
if slug[0].isdigit():
|
|
90
|
+
slug = f"case_{slug}"
|
|
91
|
+
name = f"test_{slug}"
|
|
92
|
+
# Final guard: \w matches some chars (fractions, superscripts) that are NOT
|
|
93
|
+
# valid Python identifier chars. If the slug still isn't a clean identifier,
|
|
94
|
+
# fall back rather than emit a file that won't parse.
|
|
95
|
+
if not name.isidentifier():
|
|
96
|
+
return "test_auto_generated_case"
|
|
97
|
+
return name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_initial_header(label: str | None = None) -> list:
|
|
101
|
+
# NOTE: no `import pytest` here — no generated step references pytest, so
|
|
102
|
+
# emitting it makes every generated file fail ruff's F401 (unused import).
|
|
103
|
+
# allure + log are both actually used by the step bodies.
|
|
104
|
+
#
|
|
105
|
+
# label (optional): the user's goal / action / workflow name. When given,
|
|
106
|
+
# the test is named + documented after it so the generated file is
|
|
107
|
+
# discoverable instead of an opaque `test_auto_generated_case`. None keeps
|
|
108
|
+
# the legacy name (main.py interactive recording + public-surface contract).
|
|
109
|
+
import re as _re
|
|
110
|
+
|
|
111
|
+
func_name = _slug_to_test_name(label) if label else "test_auto_generated_case"
|
|
112
|
+
safe_label = str(label).strip() if label else "AI Auto-recorded Scenario"
|
|
113
|
+
# Collapse ALL control chars (incl. \r and NUL) to spaces FIRST — an
|
|
114
|
+
# unescaped \r or \0 in a string literal makes the emitted file unparseable.
|
|
115
|
+
safe_label = _re.sub(r"[\x00-\x1f\x7f]", " ", safe_label).strip() or "AI Auto-recorded Scenario"
|
|
116
|
+
# allure.story / docstring are plain string literals — escape backslash+quote.
|
|
117
|
+
story = safe_label.replace("\\", "\\\\").replace("'", "\\'")
|
|
118
|
+
doc = safe_label.replace("\\", "\\\\").replace('"', '\\"')
|
|
119
|
+
return [
|
|
120
|
+
"# -*- coding: utf-8 -*-\n",
|
|
121
|
+
"# Auto-generated by ScreenForge AI Agent\n",
|
|
122
|
+
"import allure\n\n",
|
|
123
|
+
"from common.logs import log\n\n",
|
|
124
|
+
"@allure.feature('Core Business Flow')\n",
|
|
125
|
+
f"@allure.story('{story}')\n",
|
|
126
|
+
f"def {func_name}(d):\n",
|
|
127
|
+
f' """{doc}\n\n'
|
|
128
|
+
' Replay auto-recorded UI steps. For Web, param d is the Playwright page object."""\n',
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_to_disk(file_path: str, content: list) -> None:
|
|
133
|
+
import os
|
|
134
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
135
|
+
temp_path = file_path + ".tmp"
|
|
136
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
137
|
+
f.writelines(content)
|
|
138
|
+
os.replace(temp_path, file_path)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def launch_app(device, env_name="dev", system="android"):
|
|
142
|
+
app_target = config.APP_ENV_CONFIG.get(env_name, {}).get(system)
|
|
143
|
+
if not app_target:
|
|
144
|
+
return
|
|
145
|
+
if system == "android":
|
|
146
|
+
device.app_start(app_target)
|
|
147
|
+
log.info(f"✅ Android App launched: {app_target}")
|
|
148
|
+
elif system == "web":
|
|
149
|
+
device.goto(app_target)
|
|
150
|
+
log.info(f"✅ Web browser navigated to: {app_target}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _ensure_executor_runtime() -> None:
|
|
154
|
+
global UIExecutor, get_actual_element
|
|
155
|
+
if UIExecutor is None or get_actual_element is None:
|
|
156
|
+
from common.executor import (
|
|
157
|
+
UIExecutor as _UIExecutor,
|
|
158
|
+
)
|
|
159
|
+
from common.executor import (
|
|
160
|
+
get_actual_element as _get_actual_element,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if UIExecutor is None:
|
|
164
|
+
UIExecutor = _UIExecutor
|
|
165
|
+
if get_actual_element is None:
|
|
166
|
+
get_actual_element = _get_actual_element
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _ensure_history_manager() -> None:
|
|
170
|
+
global StepHistoryManager
|
|
171
|
+
if StepHistoryManager is None:
|
|
172
|
+
from common.history_manager import StepHistoryManager as _StepHistoryManager
|
|
173
|
+
|
|
174
|
+
StepHistoryManager = _StepHistoryManager
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ensure_preflight_runner() -> None:
|
|
178
|
+
global run_preflight
|
|
179
|
+
if run_preflight is None:
|
|
180
|
+
from common.preflight import run_preflight as _run_preflight
|
|
181
|
+
|
|
182
|
+
run_preflight = _run_preflight
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _ensure_reporter_class() -> None:
|
|
186
|
+
global RunReporter
|
|
187
|
+
if RunReporter is None:
|
|
188
|
+
from common.run_reporter import RunReporter as _RunReporter
|
|
189
|
+
|
|
190
|
+
RunReporter = _RunReporter
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ensure_ui_compressors() -> None:
|
|
194
|
+
global compress_web_dom, compress_android_xml, compress_ios_xml
|
|
195
|
+
if compress_web_dom is None:
|
|
196
|
+
from utils.utils_web import compress_web_dom as _compress_web_dom
|
|
197
|
+
|
|
198
|
+
compress_web_dom = _compress_web_dom
|
|
199
|
+
if compress_android_xml is None:
|
|
200
|
+
from utils.utils_xml import compress_android_xml as _compress_android_xml
|
|
201
|
+
|
|
202
|
+
compress_android_xml = _compress_android_xml
|
|
203
|
+
if compress_ios_xml is None:
|
|
204
|
+
from utils.utils_ios import compress_ios_xml as _compress_ios_xml
|
|
205
|
+
|
|
206
|
+
compress_ios_xml = _compress_ios_xml
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ensure_adapter_factories() -> None:
|
|
210
|
+
global AndroidU2Adapter, IosWdaAdapter, WebPlaywrightAdapter
|
|
211
|
+
if (
|
|
212
|
+
AndroidU2Adapter is None
|
|
213
|
+
or IosWdaAdapter is None
|
|
214
|
+
or WebPlaywrightAdapter is None
|
|
215
|
+
):
|
|
216
|
+
from common.adapters import (
|
|
217
|
+
AndroidU2Adapter as _AndroidU2Adapter,
|
|
218
|
+
)
|
|
219
|
+
from common.adapters import (
|
|
220
|
+
IosWdaAdapter as _IosWdaAdapter,
|
|
221
|
+
)
|
|
222
|
+
from common.adapters import (
|
|
223
|
+
WebPlaywrightAdapter as _WebPlaywrightAdapter,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if AndroidU2Adapter is None:
|
|
227
|
+
AndroidU2Adapter = _AndroidU2Adapter
|
|
228
|
+
if IosWdaAdapter is None:
|
|
229
|
+
IosWdaAdapter = _IosWdaAdapter
|
|
230
|
+
if WebPlaywrightAdapter is None:
|
|
231
|
+
WebPlaywrightAdapter = _WebPlaywrightAdapter
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ensure_runtime_classes() -> None:
|
|
235
|
+
global AutonomousBrain
|
|
236
|
+
if AutonomousBrain is None:
|
|
237
|
+
from common.ai_autonomous import AutonomousBrain as _AutonomousBrain
|
|
238
|
+
|
|
239
|
+
AutonomousBrain = _AutonomousBrain
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ensure_workflow_loader() -> None:
|
|
243
|
+
global load_workflow_file
|
|
244
|
+
global WorkflowLoadError
|
|
245
|
+
global parse_workflow_var_overrides
|
|
246
|
+
global resolve_workflow_definition
|
|
247
|
+
if (
|
|
248
|
+
load_workflow_file is None
|
|
249
|
+
or WorkflowLoadError is None
|
|
250
|
+
or parse_workflow_var_overrides is None
|
|
251
|
+
or resolve_workflow_definition is None
|
|
252
|
+
):
|
|
253
|
+
from common.workflow_schema import (
|
|
254
|
+
WorkflowLoadError as _WorkflowLoadError,
|
|
255
|
+
)
|
|
256
|
+
from common.workflow_schema import (
|
|
257
|
+
load_workflow_file as _load_workflow_file,
|
|
258
|
+
)
|
|
259
|
+
from common.workflow_schema import (
|
|
260
|
+
parse_workflow_var_overrides as _parse_workflow_var_overrides,
|
|
261
|
+
)
|
|
262
|
+
from common.workflow_schema import (
|
|
263
|
+
resolve_workflow_definition as _resolve_workflow_definition,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if load_workflow_file is None:
|
|
267
|
+
load_workflow_file = _load_workflow_file
|
|
268
|
+
if WorkflowLoadError is None:
|
|
269
|
+
WorkflowLoadError = _WorkflowLoadError
|
|
270
|
+
if parse_workflow_var_overrides is None:
|
|
271
|
+
parse_workflow_var_overrides = _parse_workflow_var_overrides
|
|
272
|
+
if resolve_workflow_definition is None:
|
|
273
|
+
resolve_workflow_definition = _resolve_workflow_definition
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _create_adapter(platform: str, args=None):
|
|
277
|
+
_ensure_adapter_factories()
|
|
278
|
+
if platform == "android":
|
|
279
|
+
adapter = AndroidU2Adapter()
|
|
280
|
+
if args and getattr(args, "device_serial", ""):
|
|
281
|
+
adapter._serial = args.device_serial
|
|
282
|
+
return adapter
|
|
283
|
+
if platform == "ios":
|
|
284
|
+
adapter = IosWdaAdapter()
|
|
285
|
+
if args and getattr(args, "device_url", ""):
|
|
286
|
+
adapter._wda_url = args.device_url
|
|
287
|
+
if args and getattr(args, "device_serial", ""):
|
|
288
|
+
adapter._udid = args.device_serial
|
|
289
|
+
return adapter
|
|
290
|
+
if platform == "web":
|
|
291
|
+
return WebPlaywrightAdapter()
|
|
292
|
+
raise ValueError(f"Unsupported platform: {platform}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _connect_adapter(args, reporter):
|
|
296
|
+
adapter = _create_adapter(args.platform, args)
|
|
297
|
+
adapter.setup()
|
|
298
|
+
device = adapter.driver
|
|
299
|
+
log.info(f"✅ {args.platform} platform connected and initialized")
|
|
300
|
+
try:
|
|
301
|
+
launch_app(device, args.env, args.platform)
|
|
302
|
+
reporter.emit_event("adapter_ready", platform=args.platform)
|
|
303
|
+
return adapter
|
|
304
|
+
except Exception:
|
|
305
|
+
try:
|
|
306
|
+
adapter.teardown()
|
|
307
|
+
except Exception as e:
|
|
308
|
+
log.warning(f"⚠️ [Warning] Cleanup failed during teardown: {e}")
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def current_url(adapter, platform: str) -> str:
|
|
313
|
+
"""Web page URL for agent-facing payloads; "" off-web or on error.
|
|
314
|
+
|
|
315
|
+
`adapter.driver` is the Playwright page (exposes `.url`); mobile adapters
|
|
316
|
+
have no URL concept, so we honestly return "".
|
|
317
|
+
"""
|
|
318
|
+
if platform != "web":
|
|
319
|
+
return ""
|
|
320
|
+
try:
|
|
321
|
+
return adapter.driver.url or ""
|
|
322
|
+
except Exception:
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _wait_for_platform_idle(platform: str, device) -> None:
|
|
327
|
+
try:
|
|
328
|
+
if platform == "android":
|
|
329
|
+
device.wait_activity(device.app_current()["activity"], timeout=3)
|
|
330
|
+
elif platform == "web":
|
|
331
|
+
device.wait_for_load_state("domcontentloaded")
|
|
332
|
+
except Exception:
|
|
333
|
+
time.sleep(1)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _capture_ui_state(args, adapter, reporter, step_index: int):
|
|
337
|
+
device = adapter.driver
|
|
338
|
+
_wait_for_platform_idle(args.platform, device)
|
|
339
|
+
_ensure_ui_compressors()
|
|
340
|
+
|
|
341
|
+
ui_json = "{}"
|
|
342
|
+
if args.platform == "android":
|
|
343
|
+
try:
|
|
344
|
+
ui_json = compress_android_xml(device.dump_hierarchy())
|
|
345
|
+
except Exception as e:
|
|
346
|
+
log.warning(f"⚠️ Failed to capture UI tree: {e}")
|
|
347
|
+
elif args.platform == "ios":
|
|
348
|
+
try:
|
|
349
|
+
ui_json = compress_ios_xml(device.source())
|
|
350
|
+
except Exception as e:
|
|
351
|
+
log.warning(f"⚠️ Failed to capture iOS UI tree: {e}")
|
|
352
|
+
elif args.platform == "web":
|
|
353
|
+
try:
|
|
354
|
+
ui_json = compress_web_dom(device)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
log.warning(f"⚠️ Failed to capture Web DOM: {e}")
|
|
357
|
+
|
|
358
|
+
screenshot_base64 = None
|
|
359
|
+
if args.vision:
|
|
360
|
+
try:
|
|
361
|
+
img_bytes = adapter.take_screenshot()
|
|
362
|
+
reporter.save_screenshot(img_bytes, step_index)
|
|
363
|
+
screenshot_base64 = base64.b64encode(img_bytes).decode("utf-8")
|
|
364
|
+
log.info("📸 Screenshot captured, sending to vision model.")
|
|
365
|
+
except Exception as e:
|
|
366
|
+
log.warning(f"⚠️ Screenshot failed, falling back to text-only UI tree: {e}")
|
|
367
|
+
|
|
368
|
+
return ui_json, screenshot_base64
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _SharedAdapterManager:
|
|
372
|
+
"""Shared adapter manager for MCP sessions.
|
|
373
|
+
|
|
374
|
+
Each platform adapter is created once and reused across requests.
|
|
375
|
+
Call teardown_all() on MCP server exit to clean up.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self):
|
|
379
|
+
self._adapters: dict[str, object] = {}
|
|
380
|
+
# One UIExecutor per platform, created lazily alongside the adapter and
|
|
381
|
+
# living exactly as long as it. The executor owns the web ref cache, so
|
|
382
|
+
# an inspect_ui and a follow-up `ref @N` action in the same MCP session
|
|
383
|
+
# share state via get_executor() — without any process-global.
|
|
384
|
+
self._executors: dict[str, object] = {}
|
|
385
|
+
|
|
386
|
+
def get_or_create(self, platform: str, env: str = "dev"):
|
|
387
|
+
if platform in self._adapters:
|
|
388
|
+
adapter = self._adapters[platform]
|
|
389
|
+
log.info(f"♻️ [System] Reusing existing {platform} adapter")
|
|
390
|
+
return adapter
|
|
391
|
+
from cli.parser import build_parser
|
|
392
|
+
from cli.tool_protocol_handlers import _NullRunReporter
|
|
393
|
+
|
|
394
|
+
parser = build_parser()
|
|
395
|
+
args = parser.parse_args([])
|
|
396
|
+
args.platform = platform
|
|
397
|
+
args.env = env
|
|
398
|
+
adapter = _connect_adapter(args, _NullRunReporter())
|
|
399
|
+
self._adapters[platform] = adapter
|
|
400
|
+
return adapter
|
|
401
|
+
|
|
402
|
+
def get_executor(self, platform: str, env: str = "dev"):
|
|
403
|
+
"""Return the shared UIExecutor for a platform, creating it (and its
|
|
404
|
+
adapter) on first use. Reused across inspect_ui / action requests so the
|
|
405
|
+
ref cache survives between them within one MCP session."""
|
|
406
|
+
_ensure_executor_runtime()
|
|
407
|
+
adapter = self.get_or_create(platform, env)
|
|
408
|
+
existing = self._executors.get(platform)
|
|
409
|
+
if existing is not None:
|
|
410
|
+
# Re-sync to the live driver: if the adapter reconnected or made a
|
|
411
|
+
# new page, adapter.driver may have changed under us. Cheap defensive
|
|
412
|
+
# rebind so the cached executor never drives a stale/closed handle.
|
|
413
|
+
existing.d = adapter.driver
|
|
414
|
+
return existing
|
|
415
|
+
executor = UIExecutor(adapter.driver, platform=platform)
|
|
416
|
+
self._executors[platform] = executor
|
|
417
|
+
return executor
|
|
418
|
+
|
|
419
|
+
def teardown_all(self):
|
|
420
|
+
for platform, adapter in self._adapters.items():
|
|
421
|
+
try:
|
|
422
|
+
adapter.teardown()
|
|
423
|
+
log.info(f"✅ [System] Cleaned up {platform} adapter")
|
|
424
|
+
except Exception as e:
|
|
425
|
+
log.warning(f"⚠️ [Warning] Failed to clean up {platform} adapter: {e}")
|
|
426
|
+
self._adapters.clear()
|
|
427
|
+
self._executors.clear()
|
cli/shorthand.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Shorthand CLI preprocessor — expands concise commands to full flag format.
|
|
2
|
+
|
|
3
|
+
Transforms:
|
|
4
|
+
screenforge click "Login" → --action click --locator-type text --locator-value Login
|
|
5
|
+
screenforge click "#email" → --action click --locator-type css --locator-value #email
|
|
6
|
+
screenforge click "@3" → --action click --locator-type ref --locator-value @3
|
|
7
|
+
screenforge input "#email" "admin" → --action input --locator-type css --locator-value #email --extra-value admin
|
|
8
|
+
screenforge goto "https://..." → --action goto --extra-value https://...
|
|
9
|
+
screenforge press "Enter" → --action press --extra-value Enter
|
|
10
|
+
screenforge swipe up → --action swipe --extra-value up
|
|
11
|
+
screenforge inspect → --tool-stdin (with inspect_ui on stdin)
|
|
12
|
+
screenforge demo → --demo
|
|
13
|
+
|
|
14
|
+
Passthrough: any argv starting with -- is left untouched (legacy flag mode).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from common.capabilities import GLOBAL_ACTIONS, SUPPORTED_ACTIONS
|
|
18
|
+
|
|
19
|
+
SHORTHAND_COMMANDS = set(SUPPORTED_ACTIONS) | {"inspect", "demo"}
|
|
20
|
+
|
|
21
|
+
ACTIONS_WITH_EXTRA_ONLY = GLOBAL_ACTIONS # goto, press, swipe — no locator needed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detect_locator_type(value: str) -> str:
|
|
25
|
+
if value.startswith("@"):
|
|
26
|
+
return "ref"
|
|
27
|
+
if value.startswith(("#", ".", "[")):
|
|
28
|
+
return "css"
|
|
29
|
+
return "text"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def preprocess_argv(argv: list[str]) -> list[str]:
|
|
33
|
+
"""If argv[1] is a known shorthand command (no -- prefix), expand it.
|
|
34
|
+
Returns the transformed argv list ready for argparse."""
|
|
35
|
+
if len(argv) < 2:
|
|
36
|
+
return argv
|
|
37
|
+
|
|
38
|
+
cmd = argv[1]
|
|
39
|
+
|
|
40
|
+
if cmd.startswith("-"):
|
|
41
|
+
return argv
|
|
42
|
+
|
|
43
|
+
if cmd not in SHORTHAND_COMMANDS:
|
|
44
|
+
return argv
|
|
45
|
+
|
|
46
|
+
if cmd == "demo":
|
|
47
|
+
return [argv[0], "--demo"] + argv[2:]
|
|
48
|
+
|
|
49
|
+
if cmd == "inspect":
|
|
50
|
+
return [argv[0], "--tool-stdin"] + argv[2:]
|
|
51
|
+
|
|
52
|
+
rest = argv[2:]
|
|
53
|
+
flags_from_rest = []
|
|
54
|
+
positional = []
|
|
55
|
+
i = 0
|
|
56
|
+
while i < len(rest):
|
|
57
|
+
if rest[i].startswith("-"):
|
|
58
|
+
flags_from_rest.append(rest[i])
|
|
59
|
+
# Grab the flag's value if it's not another flag
|
|
60
|
+
if i + 1 < len(rest) and not rest[i + 1].startswith("-"):
|
|
61
|
+
flags_from_rest.append(rest[i + 1])
|
|
62
|
+
i += 2
|
|
63
|
+
else:
|
|
64
|
+
i += 1
|
|
65
|
+
else:
|
|
66
|
+
positional.append(rest[i])
|
|
67
|
+
i += 1
|
|
68
|
+
|
|
69
|
+
expanded = [argv[0], "--action", cmd]
|
|
70
|
+
|
|
71
|
+
if cmd in ACTIONS_WITH_EXTRA_ONLY:
|
|
72
|
+
if positional:
|
|
73
|
+
expanded += ["--extra-value", positional[0]]
|
|
74
|
+
positional = positional[1:]
|
|
75
|
+
else:
|
|
76
|
+
if positional:
|
|
77
|
+
locator_val = positional[0]
|
|
78
|
+
locator_type = _detect_locator_type(locator_val)
|
|
79
|
+
expanded += ["--locator-type", locator_type, "--locator-value", locator_val]
|
|
80
|
+
positional = positional[1:]
|
|
81
|
+
if positional:
|
|
82
|
+
expanded += ["--extra-value", positional[0]]
|
|
83
|
+
positional = positional[1:]
|
|
84
|
+
|
|
85
|
+
expanded += flags_from_rest
|
|
86
|
+
|
|
87
|
+
if "--platform" not in flags_from_rest:
|
|
88
|
+
expanded += ["--platform", "web"]
|
|
89
|
+
|
|
90
|
+
return expanded
|