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/modes/action.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Single-action execution mode."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import cli.shared as _shared
|
|
8
|
+
from cli.reporter import (
|
|
9
|
+
_apply_resume_summary,
|
|
10
|
+
_build_action_summary,
|
|
11
|
+
_build_inline_action_data,
|
|
12
|
+
_build_reporter,
|
|
13
|
+
_emit_run_started,
|
|
14
|
+
)
|
|
15
|
+
from cli.shared import (
|
|
16
|
+
_capture_ui_state,
|
|
17
|
+
_connect_adapter,
|
|
18
|
+
_ensure_executor_runtime,
|
|
19
|
+
_ensure_history_manager,
|
|
20
|
+
_ensure_ui_compressors,
|
|
21
|
+
_SharedAdapterManager,
|
|
22
|
+
current_url,
|
|
23
|
+
get_initial_header,
|
|
24
|
+
log,
|
|
25
|
+
save_to_disk,
|
|
26
|
+
)
|
|
27
|
+
from common.failure_diagnosis import diagnose
|
|
28
|
+
from common.runtime_modes import MODE_RUN
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_failure_payload(
|
|
32
|
+
*,
|
|
33
|
+
action_name: str,
|
|
34
|
+
platform: str,
|
|
35
|
+
assertion_failed: bool,
|
|
36
|
+
error_code: str,
|
|
37
|
+
locator_value: str,
|
|
38
|
+
ui_tree: dict,
|
|
39
|
+
current_url: str,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Assemble the --action --json failure payload.
|
|
42
|
+
|
|
43
|
+
A failed assertion is a verdict: keep the original bare shape (no diagnosis,
|
|
44
|
+
no page, no retry bait). An engine_error is where the agent needs help, so
|
|
45
|
+
enrich it with the did-you-mean diagnosis, the current ui_tree, and the URL.
|
|
46
|
+
|
|
47
|
+
Note: ui_tree, element_count, and current_url are emitted ONLY for
|
|
48
|
+
engine_error — the assertion_failed path returns the bare verdict above.
|
|
49
|
+
"""
|
|
50
|
+
payload = {
|
|
51
|
+
"ok": False,
|
|
52
|
+
"action": action_name,
|
|
53
|
+
"platform": platform,
|
|
54
|
+
"result": "assertion_failed" if assertion_failed else "engine_error",
|
|
55
|
+
"assertion_failed": assertion_failed,
|
|
56
|
+
"error": (
|
|
57
|
+
f"Assertion failed: {action_name}"
|
|
58
|
+
if assertion_failed
|
|
59
|
+
else f"Action failed: {action_name}"
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
if assertion_failed:
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
diag = diagnose(
|
|
66
|
+
error_code=error_code or "E037",
|
|
67
|
+
locator_value=locator_value or "",
|
|
68
|
+
ui_elements=ui_tree.get("ui_elements", []) or [],
|
|
69
|
+
)
|
|
70
|
+
payload.update(diag.to_dict())
|
|
71
|
+
payload["ui_tree"] = ui_tree
|
|
72
|
+
payload["element_count"] = len(ui_tree.get("ui_elements", []) or [])
|
|
73
|
+
payload["current_url"] = current_url
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_action_default_mode(
|
|
78
|
+
args,
|
|
79
|
+
output_script_path: str,
|
|
80
|
+
resume_context: dict,
|
|
81
|
+
shared_adapter_manager: _SharedAdapterManager | None = None,
|
|
82
|
+
) -> int:
|
|
83
|
+
adapter = None
|
|
84
|
+
owns_adapter = False
|
|
85
|
+
json_mode = args.json
|
|
86
|
+
reporter = _build_reporter(args, output_script_path, MODE_RUN, json_output=False)
|
|
87
|
+
exit_code = 1
|
|
88
|
+
final_status = "failed"
|
|
89
|
+
final_error = ""
|
|
90
|
+
steps_executed = 0
|
|
91
|
+
_emit_run_started(reporter, args, output_script_path, MODE_RUN)
|
|
92
|
+
_apply_resume_summary(reporter, resume_context)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
action_data = _build_inline_action_data(args)
|
|
96
|
+
action_summary = _build_action_summary(args, action_data, executed_steps=0)
|
|
97
|
+
reporter.update_control_summary(
|
|
98
|
+
control_kind="action",
|
|
99
|
+
control_label=action_summary["action_name"],
|
|
100
|
+
source_ref="inline://action",
|
|
101
|
+
action=action_summary["action"],
|
|
102
|
+
locator_type=action_summary["locator_type"],
|
|
103
|
+
locator_value=action_summary["locator_value"],
|
|
104
|
+
extra_value=action_summary["extra_value"],
|
|
105
|
+
)
|
|
106
|
+
reporter.emit_event(
|
|
107
|
+
"action_loaded",
|
|
108
|
+
action_name=action_summary["action_name"],
|
|
109
|
+
action=action_summary["action"],
|
|
110
|
+
locator_type=action_summary["locator_type"],
|
|
111
|
+
locator_value=action_summary["locator_value"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if shared_adapter_manager:
|
|
116
|
+
adapter = shared_adapter_manager.get_or_create(args.platform, args.env)
|
|
117
|
+
else:
|
|
118
|
+
adapter = _connect_adapter(args, reporter)
|
|
119
|
+
owns_adapter = True
|
|
120
|
+
device = adapter.driver
|
|
121
|
+
except Exception as e:
|
|
122
|
+
final_error = str(e)
|
|
123
|
+
reporter.emit_event("startup_failed", platform=args.platform, error=str(e))
|
|
124
|
+
log.error(f"❌ [Error] {args.platform} connection failed: {e}")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
_ensure_history_manager()
|
|
128
|
+
_ensure_executor_runtime()
|
|
129
|
+
|
|
130
|
+
if os.path.exists(output_script_path):
|
|
131
|
+
with open(output_script_path, "r", encoding="utf-8") as f:
|
|
132
|
+
existing_lines = f.readlines()
|
|
133
|
+
history_manager = _shared.StepHistoryManager(initial_content=existing_lines)
|
|
134
|
+
log.info(f"📎 [System] Append mode: continuing on existing script ({output_script_path})")
|
|
135
|
+
else:
|
|
136
|
+
# Name the test after the action so the file is discoverable.
|
|
137
|
+
header = get_initial_header(label=action_data.get("name") or None)
|
|
138
|
+
history_manager = _shared.StepHistoryManager(initial_content=header)
|
|
139
|
+
save_to_disk(output_script_path, header)
|
|
140
|
+
|
|
141
|
+
if shared_adapter_manager:
|
|
142
|
+
# Reuse the session's shared executor so a `ref @N` action resolves
|
|
143
|
+
# against the cache a prior inspect_ui populated (same MCP session).
|
|
144
|
+
executor = shared_adapter_manager.get_executor(args.platform, args.env)
|
|
145
|
+
else:
|
|
146
|
+
executor = _shared.UIExecutor(device, platform=args.platform)
|
|
147
|
+
|
|
148
|
+
reporter.emit_event(
|
|
149
|
+
"step_started",
|
|
150
|
+
step=1,
|
|
151
|
+
source="action",
|
|
152
|
+
step_name=action_data["name"],
|
|
153
|
+
)
|
|
154
|
+
result = executor.execute_and_record(action_data)
|
|
155
|
+
if not result.get("success"):
|
|
156
|
+
assertion_failed = bool(result.get("assertion_failed"))
|
|
157
|
+
if assertion_failed:
|
|
158
|
+
final_error = f"Assertion failed: {action_data['name']}"
|
|
159
|
+
else:
|
|
160
|
+
final_error = f"Action failed: {action_data['name']}"
|
|
161
|
+
reporter.emit_event(
|
|
162
|
+
"action_executed",
|
|
163
|
+
step=1,
|
|
164
|
+
success=False,
|
|
165
|
+
action_description=action_data["name"],
|
|
166
|
+
)
|
|
167
|
+
log.error(f"❌ [Action] Failed: {action_data['name']}")
|
|
168
|
+
if json_mode:
|
|
169
|
+
# Distinguish a verification verdict (assertion_failed=true: the
|
|
170
|
+
# SUT did not meet the assertion — do NOT retry) from a real
|
|
171
|
+
# engine error. Only engine errors get the did-you-mean
|
|
172
|
+
# diagnosis + current ui_tree (the agent needs the page to
|
|
173
|
+
# recover); a verdict stays a bare result so we never bait a
|
|
174
|
+
# retry on a legitimately-failed assertion.
|
|
175
|
+
# Capture the URL at the moment of failure, BEFORE compress_web_dom
|
|
176
|
+
# traverses the DOM (a mid-redirect click could otherwise settle the
|
|
177
|
+
# navigation and report the post-redirect URL).
|
|
178
|
+
page_url = current_url(adapter, args.platform)
|
|
179
|
+
ui_tree = {}
|
|
180
|
+
if not assertion_failed:
|
|
181
|
+
_ensure_ui_compressors()
|
|
182
|
+
try:
|
|
183
|
+
ui_json, _ = _capture_ui_state(args, adapter, reporter, 1)
|
|
184
|
+
ui_tree = json.loads(ui_json)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
# Degrade (connection lost / non-JSON) but don't hide it:
|
|
187
|
+
# a silent empty tree yields empty candidates with no signal.
|
|
188
|
+
log.warning(f"⚠️ [Action] Failed to capture post-failure UI state: {e}")
|
|
189
|
+
ui_tree = {}
|
|
190
|
+
error_code = result.get("error_code", "") or ""
|
|
191
|
+
if not error_code and not assertion_failed:
|
|
192
|
+
log.warning("⚠️ [Action] executor returned no error_code; defaulting to E037")
|
|
193
|
+
payload = build_failure_payload(
|
|
194
|
+
action_name=action_data["name"],
|
|
195
|
+
platform=args.platform,
|
|
196
|
+
assertion_failed=assertion_failed,
|
|
197
|
+
error_code=error_code,
|
|
198
|
+
locator_value=getattr(args, "locator_value", "") or "",
|
|
199
|
+
ui_tree=ui_tree,
|
|
200
|
+
current_url=page_url,
|
|
201
|
+
)
|
|
202
|
+
json.dump(payload, sys.stdout, ensure_ascii=False)
|
|
203
|
+
sys.stdout.write("\n")
|
|
204
|
+
sys.stdout.flush()
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
history_manager.add_step(result["code_lines"], result["action_description"])
|
|
208
|
+
save_to_disk(output_script_path, history_manager.get_current_file_content())
|
|
209
|
+
reporter.emit_event(
|
|
210
|
+
"action_executed",
|
|
211
|
+
step=1,
|
|
212
|
+
success=True,
|
|
213
|
+
action_description=result["action_description"],
|
|
214
|
+
)
|
|
215
|
+
steps_executed = 1
|
|
216
|
+
reporter.update_summary(
|
|
217
|
+
action_summary=_build_action_summary(
|
|
218
|
+
args, action_data, executed_steps=steps_executed
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
reporter.update_control_summary(executed_steps=steps_executed)
|
|
222
|
+
final_status = "success"
|
|
223
|
+
exit_code = 0
|
|
224
|
+
|
|
225
|
+
if json_mode:
|
|
226
|
+
_ensure_ui_compressors()
|
|
227
|
+
ui_json, _ = _capture_ui_state(args, adapter, reporter, 1)
|
|
228
|
+
try:
|
|
229
|
+
ui_tree = json.loads(ui_json)
|
|
230
|
+
except (json.JSONDecodeError, TypeError):
|
|
231
|
+
ui_tree = {}
|
|
232
|
+
json.dump({
|
|
233
|
+
"ok": True,
|
|
234
|
+
"action": action_data["name"],
|
|
235
|
+
"platform": args.platform,
|
|
236
|
+
"ui_tree": ui_tree,
|
|
237
|
+
"element_count": len(ui_tree.get("ui_elements", [])),
|
|
238
|
+
"output_script": output_script_path,
|
|
239
|
+
"current_url": current_url(adapter, args.platform),
|
|
240
|
+
}, sys.stdout, ensure_ascii=False)
|
|
241
|
+
sys.stdout.write("\n")
|
|
242
|
+
sys.stdout.flush()
|
|
243
|
+
|
|
244
|
+
return 0
|
|
245
|
+
except Exception as e:
|
|
246
|
+
final_error = str(e)
|
|
247
|
+
reporter.emit_event("action_run_failed", error=str(e))
|
|
248
|
+
log.error(f"❌ [Action] Failed: {e}")
|
|
249
|
+
return 1
|
|
250
|
+
finally:
|
|
251
|
+
reporter.finalize(
|
|
252
|
+
status=final_status,
|
|
253
|
+
exit_code=exit_code,
|
|
254
|
+
steps_executed=steps_executed,
|
|
255
|
+
last_error=final_error,
|
|
256
|
+
)
|
|
257
|
+
log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
|
|
258
|
+
if owns_adapter and adapter:
|
|
259
|
+
try:
|
|
260
|
+
adapter.teardown()
|
|
261
|
+
except Exception as e:
|
|
262
|
+
log.warning(f"⚠️ [Warning] Cleanup failed: {e}")
|
cli/modes/default.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Default autonomous brain loop mode."""
|
|
2
|
+
|
|
3
|
+
import cli.shared as _shared
|
|
4
|
+
from cli.reporter import (
|
|
5
|
+
_apply_resume_summary,
|
|
6
|
+
_build_reporter,
|
|
7
|
+
_emit_run_started,
|
|
8
|
+
)
|
|
9
|
+
from cli.shared import (
|
|
10
|
+
_capture_ui_state,
|
|
11
|
+
_connect_adapter,
|
|
12
|
+
_ensure_executor_runtime,
|
|
13
|
+
_ensure_history_manager,
|
|
14
|
+
_ensure_runtime_classes,
|
|
15
|
+
get_initial_header,
|
|
16
|
+
log,
|
|
17
|
+
save_to_disk,
|
|
18
|
+
)
|
|
19
|
+
from common.runtime_modes import MODE_RUN
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_default_mode(
|
|
23
|
+
args,
|
|
24
|
+
output_script_path: str,
|
|
25
|
+
context_content: str,
|
|
26
|
+
resume_context: dict,
|
|
27
|
+
) -> int:
|
|
28
|
+
adapter = None
|
|
29
|
+
reporter = _build_reporter(args, output_script_path, MODE_RUN)
|
|
30
|
+
exit_code = 1
|
|
31
|
+
final_status = "failed"
|
|
32
|
+
final_error = ""
|
|
33
|
+
steps_executed = 0
|
|
34
|
+
_emit_run_started(reporter, args, output_script_path, MODE_RUN)
|
|
35
|
+
_apply_resume_summary(reporter, resume_context)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
try:
|
|
39
|
+
adapter = _connect_adapter(args, reporter)
|
|
40
|
+
device = adapter.driver
|
|
41
|
+
except Exception as e:
|
|
42
|
+
final_error = str(e)
|
|
43
|
+
reporter.emit_event("startup_failed", platform=args.platform, error=str(e))
|
|
44
|
+
log.error(f"❌ [Error] {args.platform} connection failed: {e}")
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
_ensure_history_manager()
|
|
48
|
+
_ensure_executor_runtime()
|
|
49
|
+
header = get_initial_header(label=str(getattr(args, "goal", "")).strip() or None)
|
|
50
|
+
history_manager = _shared.StepHistoryManager(initial_content=header)
|
|
51
|
+
save_to_disk(output_script_path, header)
|
|
52
|
+
|
|
53
|
+
_ensure_runtime_classes()
|
|
54
|
+
brain = _shared.AutonomousBrain()
|
|
55
|
+
executor = _shared.UIExecutor(device, platform=args.platform)
|
|
56
|
+
|
|
57
|
+
step_count = 0
|
|
58
|
+
last_error = ""
|
|
59
|
+
consecutive_failures = 0
|
|
60
|
+
last_ui_json = ""
|
|
61
|
+
|
|
62
|
+
while step_count < args.max_steps:
|
|
63
|
+
step_count += 1
|
|
64
|
+
steps_executed = step_count
|
|
65
|
+
log.info(f"\n--- 🔄 Step {step_count} ---")
|
|
66
|
+
reporter.emit_event("step_started", step=step_count)
|
|
67
|
+
|
|
68
|
+
ui_json, screenshot_base64 = _capture_ui_state(
|
|
69
|
+
args, adapter, reporter, step_count
|
|
70
|
+
)
|
|
71
|
+
current_history = history_manager.get_history()
|
|
72
|
+
|
|
73
|
+
if last_ui_json == ui_json and step_count > 1 and not last_error:
|
|
74
|
+
last_error = "[System Warning] The previous action was executed but the page UI did not change. Possible causes: invalid input, unchecked checkbox, or invisible overlay. Do NOT repeat the same action — try a different strategy."
|
|
75
|
+
log.warning("[E020] UI stagnation detected: action executed but page did not change. Possible causes: invalid input, unchecked checkbox, or invisible overlay.")
|
|
76
|
+
|
|
77
|
+
last_ui_json = ui_json
|
|
78
|
+
|
|
79
|
+
decision_data = brain.get_next_autonomous_action(
|
|
80
|
+
goal=args.goal,
|
|
81
|
+
context=context_content,
|
|
82
|
+
ui_json=ui_json,
|
|
83
|
+
history=current_history,
|
|
84
|
+
platform=args.platform,
|
|
85
|
+
last_error=last_error,
|
|
86
|
+
screenshot_base64=screenshot_base64,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
status = decision_data.get("status")
|
|
90
|
+
action_data = decision_data.get("result", {})
|
|
91
|
+
last_error = ""
|
|
92
|
+
reporter.emit_event(
|
|
93
|
+
"decision_received",
|
|
94
|
+
step=step_count,
|
|
95
|
+
status=status,
|
|
96
|
+
action=action_data.get("action", ""),
|
|
97
|
+
locator_type=action_data.get("locator_type", ""),
|
|
98
|
+
locator_value=action_data.get("locator_value", ""),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if status == "success":
|
|
102
|
+
if action_data and action_data.get("action"):
|
|
103
|
+
log.info(
|
|
104
|
+
"🔍 Final action/assertion detected with success status, executing..."
|
|
105
|
+
)
|
|
106
|
+
result = executor.execute_and_record(action_data)
|
|
107
|
+
if result.get("success"):
|
|
108
|
+
history_manager.add_step(
|
|
109
|
+
result["code_lines"], result["action_description"]
|
|
110
|
+
)
|
|
111
|
+
save_to_disk(
|
|
112
|
+
output_script_path,
|
|
113
|
+
history_manager.get_current_file_content(),
|
|
114
|
+
)
|
|
115
|
+
log.info(f"✅ Final action succeeded: {result['action_description']}")
|
|
116
|
+
reporter.emit_event(
|
|
117
|
+
"action_executed",
|
|
118
|
+
step=step_count,
|
|
119
|
+
success=True,
|
|
120
|
+
action_description=result["action_description"],
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
final_error = "Final action/assertion failed — task verification did not pass"
|
|
124
|
+
reporter.emit_event(
|
|
125
|
+
"action_executed",
|
|
126
|
+
step=step_count,
|
|
127
|
+
success=False,
|
|
128
|
+
action_description="final_action_failed",
|
|
129
|
+
)
|
|
130
|
+
log.error("❌ Final action/assertion failed — task verification did not pass")
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
final_status = "success"
|
|
134
|
+
exit_code = 0
|
|
135
|
+
log.info("🎉 [Agent] All goals and assertions achieved!")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
if status == "failed":
|
|
139
|
+
final_error = "Task cannot continue — AI determined failure"
|
|
140
|
+
log.warning("⚠️ [Agent] Task cannot continue — AI determined failure.")
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
if status == "running":
|
|
144
|
+
if not action_data:
|
|
145
|
+
last_error = "Model returned 'running' status but provided no action. Please provide a concrete action."
|
|
146
|
+
consecutive_failures += 1
|
|
147
|
+
reporter.emit_event(
|
|
148
|
+
"action_executed",
|
|
149
|
+
step=step_count,
|
|
150
|
+
success=False,
|
|
151
|
+
action_description="missing_action",
|
|
152
|
+
)
|
|
153
|
+
elif action_data.get("action") == "not_found":
|
|
154
|
+
# The model explicitly reports the target is absent from the
|
|
155
|
+
# UI tree and needs visual verification. This is not an
|
|
156
|
+
# engine error and must NOT count toward the circuit breaker
|
|
157
|
+
# (otherwise two not_found in a row trips it before vision is
|
|
158
|
+
# ever tried, since max_retries defaults to 2).
|
|
159
|
+
last_error = (
|
|
160
|
+
"Model reported the target element is not in the current "
|
|
161
|
+
"UI tree (action='not_found'). Re-run with --vision to let "
|
|
162
|
+
"the visual model locate it, or try a different strategy/"
|
|
163
|
+
"scroll to bring it into view."
|
|
164
|
+
)
|
|
165
|
+
log.warning(
|
|
166
|
+
"[Agent] Model returned 'not_found' — target absent from UI "
|
|
167
|
+
"tree. Suggesting visual fallback (no circuit-breaker penalty)."
|
|
168
|
+
)
|
|
169
|
+
reporter.emit_event(
|
|
170
|
+
"action_executed",
|
|
171
|
+
step=step_count,
|
|
172
|
+
success=False,
|
|
173
|
+
action_description="not_found",
|
|
174
|
+
)
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
result = executor.execute_and_record(action_data)
|
|
178
|
+
if result.get("success"):
|
|
179
|
+
consecutive_failures = 0
|
|
180
|
+
history_manager.add_step(
|
|
181
|
+
result["code_lines"], result["action_description"]
|
|
182
|
+
)
|
|
183
|
+
save_to_disk(
|
|
184
|
+
output_script_path,
|
|
185
|
+
history_manager.get_current_file_content(),
|
|
186
|
+
)
|
|
187
|
+
log.info(f"Action succeeded: {result['action_description']}")
|
|
188
|
+
reporter.emit_event(
|
|
189
|
+
"action_executed",
|
|
190
|
+
step=step_count,
|
|
191
|
+
success=True,
|
|
192
|
+
action_description=result["action_description"],
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
consecutive_failures += 1
|
|
196
|
+
action_repr = f"{action_data.get('action')} - {action_data.get('locator_type')}={action_data.get('locator_value')}"
|
|
197
|
+
last_error = f"Action [{action_repr}] failed — element not found or not interactable on the current page."
|
|
198
|
+
reporter.emit_event(
|
|
199
|
+
"action_executed",
|
|
200
|
+
step=step_count,
|
|
201
|
+
success=False,
|
|
202
|
+
action_description=action_repr,
|
|
203
|
+
)
|
|
204
|
+
log.warning(
|
|
205
|
+
f"[E021] Step failed (attempt {consecutive_failures}/{args.max_retries}). Self-heal engine will retry with adjusted strategy."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if consecutive_failures >= args.max_retries:
|
|
209
|
+
action_repr = f"{action_data.get('action')} - {action_data.get('locator_type')}={action_data.get('locator_value')}"
|
|
210
|
+
final_error = f"Circuit breaker triggered after {args.max_retries} consecutive failures on: {action_repr}"
|
|
211
|
+
log.error(
|
|
212
|
+
f"[E022] Circuit breaker triggered: {args.max_retries} consecutive failures on [{action_repr}]. "
|
|
213
|
+
f"Last error: element not found or not interactable. "
|
|
214
|
+
f"Fix: run 'screenforge --action click --platform {args.platform} --dry-run ...' to inspect current page state, "
|
|
215
|
+
f"or adjust your workflow/locator strategy."
|
|
216
|
+
)
|
|
217
|
+
return 1
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
final_error = f"Unknown decision status: {status}"
|
|
221
|
+
log.error(f"[E023] Unknown decision status '{status}' returned by AI brain. This is likely a model parsing issue. Fix: check MODEL_NAME supports structured JSON output")
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
final_error = f"Max step limit reached ({args.max_steps} steps)"
|
|
225
|
+
log.warning(
|
|
226
|
+
f"[E024] Exploration exceeded max step limit ({args.max_steps}). Possible infinite loop. Fix: increase --max_steps or refine your --goal to be more specific."
|
|
227
|
+
)
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
final_error = "Interrupted by user (KeyboardInterrupt)"
|
|
232
|
+
reporter.emit_event("interrupted", reason="KeyboardInterrupt")
|
|
233
|
+
log.warning("\n⚠️ Interrupted — safely aborting...")
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
finally:
|
|
237
|
+
reporter.finalize(
|
|
238
|
+
status=final_status,
|
|
239
|
+
exit_code=exit_code,
|
|
240
|
+
steps_executed=steps_executed,
|
|
241
|
+
last_error=final_error,
|
|
242
|
+
)
|
|
243
|
+
log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
|
|
244
|
+
if adapter:
|
|
245
|
+
try:
|
|
246
|
+
adapter.teardown()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
log.warning(f"⚠️ [Warning] Cleanup failed: {e}")
|
cli/modes/demo.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Demo mode — showcases ScreenForge's full flow without API key or device."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from cli._version import __version__
|
|
7
|
+
|
|
8
|
+
DEMO_SCENARIO = {
|
|
9
|
+
"title": "Login Flow Test",
|
|
10
|
+
"url": "https://demo.screenforge.dev",
|
|
11
|
+
"steps": [
|
|
12
|
+
{
|
|
13
|
+
"description": "Navigate to login page",
|
|
14
|
+
"action": "goto",
|
|
15
|
+
"target": "https://demo.screenforge.dev/login",
|
|
16
|
+
"ui_tree_snippet": (
|
|
17
|
+
'<div id="app">\n'
|
|
18
|
+
' <nav>\n'
|
|
19
|
+
' <a href="/login">Sign In</a>\n'
|
|
20
|
+
' <a href="/register">Sign Up</a>\n'
|
|
21
|
+
' </nav>\n'
|
|
22
|
+
'</div>'
|
|
23
|
+
),
|
|
24
|
+
"generated_code": 'page.goto("https://demo.screenforge.dev/login")',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"description": "Enter username",
|
|
28
|
+
"action": "input",
|
|
29
|
+
"target": "#email → 'user@example.com'",
|
|
30
|
+
"ui_tree_snippet": (
|
|
31
|
+
'<form id="login-form">\n'
|
|
32
|
+
' <input id="email" type="email" placeholder="Email" />\n'
|
|
33
|
+
' <input id="password" type="password" placeholder="Password" />\n'
|
|
34
|
+
' <button type="submit">Sign In</button>\n'
|
|
35
|
+
'</form>'
|
|
36
|
+
),
|
|
37
|
+
"generated_code": (
|
|
38
|
+
'page.locator("#email").first.fill("user@example.com")'
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"description": "Enter password",
|
|
43
|
+
"action": "input",
|
|
44
|
+
"target": "#password → '••••••••'",
|
|
45
|
+
"ui_tree_snippet": None,
|
|
46
|
+
"generated_code": 'page.locator("#password").first.fill("password123")',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"description": "Click sign in button",
|
|
50
|
+
"action": "click",
|
|
51
|
+
"target": "text='Sign In'",
|
|
52
|
+
"ui_tree_snippet": None,
|
|
53
|
+
"generated_code": "page.get_by_text('Sign In').first.click()",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"description": "Verify dashboard loaded",
|
|
57
|
+
"action": "assert_exist",
|
|
58
|
+
"target": "text='Welcome back'",
|
|
59
|
+
"ui_tree_snippet": (
|
|
60
|
+
'<main>\n'
|
|
61
|
+
' <h1>Welcome back</h1>\n'
|
|
62
|
+
' <div class="dashboard">...</div>\n'
|
|
63
|
+
'</main>'
|
|
64
|
+
),
|
|
65
|
+
"generated_code": (
|
|
66
|
+
'expect(page.get_by_text("Welcome back").first).to_be_visible()'
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
PYTEST_OUTPUT = '''import allure
|
|
73
|
+
import pytest
|
|
74
|
+
from playwright.sync_api import Page, expect
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@allure.feature("Authentication")
|
|
78
|
+
@allure.story("Login Flow")
|
|
79
|
+
class TestLogin:
|
|
80
|
+
@allure.step("Navigate to login page")
|
|
81
|
+
def test_login_success(self, page: Page):
|
|
82
|
+
page.goto("https://demo.screenforge.dev/login")
|
|
83
|
+
|
|
84
|
+
with allure.step("Enter credentials"):
|
|
85
|
+
page.locator("#email").first.fill("user@example.com")
|
|
86
|
+
page.locator("#password").first.fill("password123")
|
|
87
|
+
|
|
88
|
+
with allure.step("Submit login form"):
|
|
89
|
+
page.get_by_text('Sign In').first.click()
|
|
90
|
+
|
|
91
|
+
with allure.step("Verify dashboard loaded"):
|
|
92
|
+
expect(page.get_by_text("Welcome back").first).to_be_visible()
|
|
93
|
+
'''
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _print_slow(text: str, delay: float = 0.02) -> None:
|
|
97
|
+
for char in text:
|
|
98
|
+
sys.stdout.write(char)
|
|
99
|
+
sys.stdout.flush()
|
|
100
|
+
time.sleep(delay)
|
|
101
|
+
sys.stdout.write("\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _print_step_header(step_num: int, total: int, description: str) -> None:
|
|
105
|
+
print(f"\n [{step_num}/{total}] {description}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_demo_mode() -> int:
|
|
109
|
+
scenario = DEMO_SCENARIO
|
|
110
|
+
steps = scenario["steps"]
|
|
111
|
+
|
|
112
|
+
print()
|
|
113
|
+
print(f" screenforge v{__version__} — demo mode")
|
|
114
|
+
print(f" Scenario: {scenario['title']}")
|
|
115
|
+
print(f" Target: {scenario['url']} (simulated)")
|
|
116
|
+
print(" " + "-" * 50)
|
|
117
|
+
time.sleep(0.5)
|
|
118
|
+
|
|
119
|
+
print("\n Connecting to browser...", end="")
|
|
120
|
+
time.sleep(0.3)
|
|
121
|
+
print(" OK (simulated)")
|
|
122
|
+
|
|
123
|
+
for i, step in enumerate(steps, 1):
|
|
124
|
+
_print_step_header(i, len(steps), step["description"])
|
|
125
|
+
time.sleep(0.3)
|
|
126
|
+
|
|
127
|
+
if step["ui_tree_snippet"]:
|
|
128
|
+
print(" inspect_ui:")
|
|
129
|
+
for line in step["ui_tree_snippet"].split("\n"):
|
|
130
|
+
print(f" {line}")
|
|
131
|
+
time.sleep(0.2)
|
|
132
|
+
|
|
133
|
+
print(f" action: {step['action']} → {step['target']}")
|
|
134
|
+
time.sleep(0.2)
|
|
135
|
+
|
|
136
|
+
print(f" codegen: {step['generated_code']}")
|
|
137
|
+
time.sleep(0.3)
|
|
138
|
+
|
|
139
|
+
print("\n " + "-" * 50)
|
|
140
|
+
print(" Generating pytest script...\n")
|
|
141
|
+
time.sleep(0.4)
|
|
142
|
+
|
|
143
|
+
for line in PYTEST_OUTPUT.strip().split("\n"):
|
|
144
|
+
_print_slow(f" {line}", delay=0.008)
|
|
145
|
+
|
|
146
|
+
print("\n " + "-" * 50)
|
|
147
|
+
print(" Demo complete!")
|
|
148
|
+
print()
|
|
149
|
+
print(" What just happened:")
|
|
150
|
+
print(" 1. ScreenForge inspected the page structure (inspect_ui)")
|
|
151
|
+
print(" 2. AI analyzed the DOM and chose actions")
|
|
152
|
+
print(" 3. Actions were executed step by step")
|
|
153
|
+
print(" 4. A production-ready pytest script was generated")
|
|
154
|
+
print()
|
|
155
|
+
print(" To do this for real:")
|
|
156
|
+
print(" export OPENAI_API_KEY=sk-...")
|
|
157
|
+
print(" screenforge --action click --platform web --locator-type text --locator-value 'Login'")
|
|
158
|
+
print()
|
|
159
|
+
print(" Star us on GitHub if this was useful!")
|
|
160
|
+
print()
|
|
161
|
+
|
|
162
|
+
return 0
|