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/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)
|