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/reporter.py ADDED
@@ -0,0 +1,207 @@
1
+ """Reporter helpers, context resolution, and output path management."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import cli.shared as _shared
8
+ from cli.shared import (
9
+ _ensure_reporter_class,
10
+ config,
11
+ log,
12
+ )
13
+ from common.run_resume import load_run_context
14
+ from common.runtime_modes import MODE_DOCTOR
15
+
16
+
17
+ def _resolve_control_identity(args, execution_mode: str) -> dict:
18
+ if execution_mode == MODE_DOCTOR:
19
+ return {
20
+ "control_kind": "doctor",
21
+ "control_label": "doctor",
22
+ "control_source_ref": "",
23
+ }
24
+
25
+ workflow_path = str(getattr(args, "workflow", "")).strip()
26
+ if workflow_path:
27
+ workflow_file = Path(workflow_path).expanduser().resolve()
28
+ return {
29
+ "control_kind": "workflow",
30
+ "control_label": workflow_file.stem,
31
+ "control_source_ref": str(workflow_file),
32
+ }
33
+
34
+ action = str(getattr(args, "action", "")).strip()
35
+ if action:
36
+ return {
37
+ "control_kind": "action",
38
+ "control_label": str(getattr(args, "action_name", "")).strip() or action,
39
+ "control_source_ref": "inline://action",
40
+ }
41
+
42
+ return {
43
+ "control_kind": "goal",
44
+ "control_label": str(getattr(args, "goal", "")).strip(),
45
+ "control_source_ref": str(getattr(args, "context", "")).strip(),
46
+ }
47
+
48
+
49
+ def _build_inline_action_data(args) -> dict:
50
+ locator_type = str(getattr(args, "locator_type", "")).strip() or "global"
51
+ locator_value = str(getattr(args, "locator_value", "")).strip() or "global"
52
+ extra_value = str(getattr(args, "extra_value", ""))
53
+ action_name = str(getattr(args, "action_name", "")).strip()
54
+ if not action_name:
55
+ if locator_value.lower() != "global":
56
+ action_name = f"{args.action}:{locator_value}"
57
+ elif extra_value:
58
+ action_name = f"{args.action}:{extra_value}"
59
+ else:
60
+ action_name = str(args.action).strip()
61
+
62
+ return {
63
+ "name": action_name,
64
+ "action": str(args.action).strip(),
65
+ "locator_type": locator_type,
66
+ "locator_value": locator_value,
67
+ "extra_value": extra_value,
68
+ }
69
+
70
+
71
+ def _build_action_summary(args, action_data: dict, **extra_fields) -> dict:
72
+ summary = {
73
+ "action_name": action_data.get("name", ""),
74
+ "action": action_data.get("action", ""),
75
+ "locator_type": action_data.get("locator_type", ""),
76
+ "locator_value": action_data.get("locator_value", ""),
77
+ "extra_value": action_data.get("extra_value", ""),
78
+ }
79
+ summary.update(extra_fields)
80
+ return summary
81
+
82
+
83
+ def _resolve_output_script_path(args) -> str:
84
+ if args.output:
85
+ output_script_path = args.output
86
+ else:
87
+ base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
88
+ platform_dir = os.path.join(base_dir, "test_cases", args.platform)
89
+ os.makedirs(platform_dir, exist_ok=True)
90
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
91
+ output_script_path = os.path.join(
92
+ platform_dir, f"test_auto_agent_{timestamp}.py"
93
+ )
94
+
95
+ os.makedirs(os.path.dirname(os.path.abspath(output_script_path)), exist_ok=True)
96
+ return output_script_path
97
+
98
+
99
+ def _format_resume_context(resume_context: dict) -> str:
100
+ actions = resume_context.get("successful_actions", [])
101
+ actions_str = "; ".join(actions) if actions else "N/A"
102
+ screenshot_path = resume_context.get("latest_screenshot_path", "") or "N/A"
103
+ control_summary = resume_context.get("control_summary", {}) or {}
104
+ failure_analysis = resume_context.get("failure_analysis", {}) or {}
105
+ pytest_asset = resume_context.get("pytest_asset", {}) or {}
106
+ control_kind = control_summary.get("control_kind", "") or "unknown"
107
+ control_label = control_summary.get("control_label", "") or resume_context.get("goal", "")
108
+ source_ref = control_summary.get("source_ref", "") or "N/A"
109
+ failure_category = failure_analysis.get("category", "") or "N/A"
110
+ failure_stage = failure_analysis.get("stage", "") or "N/A"
111
+ failure_summary = failure_analysis.get("summary", "") or "N/A"
112
+ failure_retryable = failure_analysis.get("retryable", "N/A")
113
+ failure_recommended_command = failure_analysis.get("recommended_command", "") or "N/A"
114
+ recovery_hint = failure_analysis.get("recovery_hint", "") or "N/A"
115
+ pytest_target = pytest_asset.get("pytest_target", "") or "N/A"
116
+ pytest_command = pytest_asset.get("pytest_command", "") or "N/A"
117
+ pytest_manifest_path = pytest_asset.get("manifest_path", "") or "N/A"
118
+ resume_commands = pytest_asset.get("resume_commands", {}) or {}
119
+ resume_dry_run_command = resume_commands.get("dry_run", "") or "N/A"
120
+ return (
121
+ "\n[Previous Run Resume Context]:\n"
122
+ f"- run_id: {resume_context.get('run_id', '')}\n"
123
+ f"- control_kind: {control_kind}\n"
124
+ f"- control_label: {control_label}\n"
125
+ f"- source_ref: {source_ref}\n"
126
+ f"- goal: {resume_context.get('goal', '')}\n"
127
+ f"- platform: {resume_context.get('platform', '')}\n"
128
+ f"- env: {resume_context.get('env', '')}\n"
129
+ f"- status: {resume_context.get('status', '')}\n"
130
+ f"- successful_actions: {actions_str}\n"
131
+ f"- last_error: {resume_context.get('last_error', '')}\n"
132
+ f"- failure_category: {failure_category}\n"
133
+ f"- failure_stage: {failure_stage}\n"
134
+ f"- failure_summary: {failure_summary}\n"
135
+ f"- failure_retryable: {failure_retryable}\n"
136
+ f"- failure_recommended_command: {failure_recommended_command}\n"
137
+ f"- recovery_hint: {recovery_hint}\n"
138
+ f"- pytest_target: {pytest_target}\n"
139
+ f"- pytest_command: {pytest_command}\n"
140
+ f"- pytest_manifest_path: {pytest_manifest_path}\n"
141
+ f"- resume_dry_run_command: {resume_dry_run_command}\n"
142
+ f"- latest_screenshot_path: {screenshot_path}\n"
143
+ )
144
+
145
+
146
+ def _load_context_content(args):
147
+ context_content = ""
148
+ if args.context and os.path.exists(args.context):
149
+ with open(args.context, "r", encoding="utf-8") as f:
150
+ context_content = f.read()
151
+ log.info(f"📄 Loaded context file: {args.context}")
152
+
153
+ resume_context = {}
154
+ if args.resume_run_id:
155
+ run_dir = Path(config.RUN_REPORT_BASE_DIR) / args.resume_run_id
156
+ resume_context = load_run_context(run_dir)
157
+ context_content = f"{context_content}{_format_resume_context(resume_context)}"
158
+ log.info(f"🧩 Resumed context from run_id={args.resume_run_id}")
159
+
160
+ return context_content, resume_context
161
+
162
+
163
+ def _build_reporter(args, output_script_path: str, execution_mode: str, *, json_output: bool | None = None):
164
+ _ensure_reporter_class()
165
+ control_identity = _resolve_control_identity(args, execution_mode)
166
+ goal_label = control_identity["control_label"] or f"{args.platform} {execution_mode}"
167
+ return _shared.RunReporter(
168
+ goal=goal_label,
169
+ platform=args.platform,
170
+ env_name=args.env,
171
+ output_script_path=output_script_path,
172
+ json_output=args.json if json_output is None else json_output,
173
+ vision_enabled=args.vision,
174
+ max_steps=args.max_steps,
175
+ execution_mode=execution_mode,
176
+ resume_from_run_id=args.resume_run_id,
177
+ control_kind=control_identity["control_kind"],
178
+ control_label=control_identity["control_label"],
179
+ control_source_ref=control_identity["control_source_ref"],
180
+ )
181
+
182
+
183
+ def _emit_run_started(reporter, args, output_script_path: str, execution_mode: str) -> None:
184
+ control_identity = _resolve_control_identity(args, execution_mode)
185
+ reporter.emit_event(
186
+ "run_started",
187
+ goal=control_identity["control_label"],
188
+ platform=args.platform,
189
+ env=args.env,
190
+ output_script_path=output_script_path,
191
+ vision_enabled=args.vision,
192
+ execution_mode=execution_mode,
193
+ resume_run_id=args.resume_run_id,
194
+ control_kind=control_identity["control_kind"],
195
+ control_label=control_identity["control_label"],
196
+ control_source_ref=control_identity["control_source_ref"],
197
+ )
198
+
199
+
200
+ def _apply_resume_summary(reporter, resume_context: dict) -> None:
201
+ reporter.update_summary(
202
+ resume_context_available=bool(resume_context),
203
+ )
204
+ if resume_context:
205
+ reporter.update_control_summary(
206
+ resume_context=resume_context.get("control_summary", {}) or {},
207
+ )
cli/session.py ADDED
@@ -0,0 +1,146 @@
1
+ """Multi-step session management for Claude Code integration.
2
+
3
+ A session groups multiple --action calls into one test file and one recording.
4
+ The recording process survives across CLI invocations via saved PID.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import time
13
+
14
+ _SESSION_DIR = os.path.abspath(os.path.join("report", "sessions"))
15
+
16
+
17
+ def _session_file(session_id: str) -> str:
18
+ return os.path.join(_SESSION_DIR, f"{session_id}.json")
19
+
20
+
21
+ def session_exists(session_id: str) -> bool:
22
+ return os.path.exists(_session_file(session_id))
23
+
24
+
25
+ def load_session(session_id: str) -> dict | None:
26
+ path = _session_file(session_id)
27
+ if not os.path.exists(path):
28
+ return None
29
+ with open(path, "r") as f:
30
+ return json.load(f)
31
+
32
+
33
+ def create_session(session_id: str, platform: str, output_path: str) -> dict:
34
+ os.makedirs(_SESSION_DIR, exist_ok=True)
35
+ session = {
36
+ "session_id": session_id,
37
+ "platform": platform,
38
+ "output_path": output_path,
39
+ "created_at": time.time(),
40
+ "updated_at": time.time(),
41
+ "steps": 0,
42
+ "recording": False,
43
+ }
44
+ _save_session(session)
45
+ return session
46
+
47
+
48
+ def update_session(session_id: str, **kwargs) -> dict:
49
+ session = load_session(session_id)
50
+ if not session:
51
+ raise ValueError(f"Session not found: {session_id}")
52
+ session.update(kwargs)
53
+ session["updated_at"] = time.time()
54
+ _save_session(session)
55
+ return session
56
+
57
+
58
+ def delete_session(session_id: str) -> None:
59
+ path = _session_file(session_id)
60
+ if os.path.exists(path):
61
+ os.remove(path)
62
+
63
+
64
+ def resolve_session_output_path(session_id: str, platform: str) -> str:
65
+ base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
66
+ platform_dir = os.path.join(base_dir, "test_cases", platform)
67
+ os.makedirs(platform_dir, exist_ok=True)
68
+ return os.path.join(platform_dir, f"test_session_{session_id}.py")
69
+
70
+
71
+ def start_session_recording(session_id: str, platform: str, udid: str = "") -> str | None:
72
+ if sys.platform != "darwin":
73
+ return None
74
+ if platform != "ios":
75
+ return None
76
+ if not udid:
77
+ try:
78
+ result = subprocess.run(
79
+ ["xcrun", "simctl", "list", "devices", "booted", "-j"],
80
+ capture_output=True, text=True, timeout=5,
81
+ )
82
+ if result.returncode == 0:
83
+ data = json.loads(result.stdout)
84
+ for runtime_devices in data.get("devices", {}).values():
85
+ for device in runtime_devices:
86
+ if device.get("state") == "Booted":
87
+ udid = device.get("udid", "")
88
+ break
89
+ if udid:
90
+ break
91
+ except Exception:
92
+ pass
93
+ if not udid:
94
+ return None
95
+
96
+ video_dir = os.path.abspath("report")
97
+ os.makedirs(video_dir, exist_ok=True)
98
+ video_path = os.path.join(video_dir, f"session_{session_id}.mov")
99
+
100
+ cmd = ["xcrun", "simctl", "io", udid, "recordVideo", video_path]
101
+ proc = subprocess.Popen(
102
+ cmd,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ preexec_fn=os.setsid,
106
+ )
107
+ time.sleep(1.0)
108
+ if proc.poll() is not None:
109
+ return None
110
+
111
+ update_session(session_id, recording_pid=proc.pid, video_path=video_path)
112
+ return video_path
113
+
114
+
115
+ def stop_session_recording(session_id: str) -> str:
116
+ session = load_session(session_id)
117
+ if not session:
118
+ return ""
119
+ pid = session.get("recording_pid")
120
+ video_path = session.get("video_path", "")
121
+ if not pid:
122
+ return ""
123
+
124
+ try:
125
+ pgid = os.getpgid(pid)
126
+ os.killpg(pgid, signal.SIGINT)
127
+ os.waitpid(pid, 0)
128
+ except (OSError, ChildProcessError):
129
+ try:
130
+ os.kill(pid, signal.SIGINT)
131
+ time.sleep(2)
132
+ except OSError:
133
+ pass
134
+
135
+ if video_path and os.path.exists(video_path):
136
+ size = os.path.getsize(video_path)
137
+ if size > 1024:
138
+ return video_path
139
+ return ""
140
+
141
+
142
+ def _save_session(session: dict) -> None:
143
+ os.makedirs(_SESSION_DIR, exist_ok=True)
144
+ path = _session_file(session["session_id"])
145
+ with open(path, "w") as f:
146
+ json.dump(session, f)