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.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. 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