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/workflow.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Workflow execution modes."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cli.shared as _shared
|
|
6
|
+
from cli.modes.dry_run import _build_resolution_hint, _preview_action_resolution
|
|
7
|
+
from cli.reporter import (
|
|
8
|
+
_apply_resume_summary,
|
|
9
|
+
_build_reporter,
|
|
10
|
+
_emit_run_started,
|
|
11
|
+
)
|
|
12
|
+
from cli.shared import (
|
|
13
|
+
_connect_adapter,
|
|
14
|
+
_ensure_executor_runtime,
|
|
15
|
+
_ensure_history_manager,
|
|
16
|
+
_ensure_workflow_loader,
|
|
17
|
+
get_initial_header,
|
|
18
|
+
log,
|
|
19
|
+
save_to_disk,
|
|
20
|
+
)
|
|
21
|
+
from common.runtime_modes import MODE_DRY_RUN, MODE_PLAN_ONLY, MODE_RUN
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_workflow_definition(args):
|
|
25
|
+
_ensure_workflow_loader()
|
|
26
|
+
workflow = _shared.load_workflow_file(args.workflow)
|
|
27
|
+
workflow_var_overrides = _shared.parse_workflow_var_overrides(args.workflow_var)
|
|
28
|
+
workflow = _shared.resolve_workflow_definition(workflow, workflow_var_overrides)
|
|
29
|
+
|
|
30
|
+
if workflow.platform and workflow.platform != args.platform:
|
|
31
|
+
raise _shared.WorkflowLoadError(
|
|
32
|
+
f"Workflow platform [{workflow.platform}] conflicts with --platform [{args.platform}]"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return workflow
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _workflow_step_display_name(step, index: int) -> str:
|
|
39
|
+
if getattr(step, "name", ""):
|
|
40
|
+
return step.name
|
|
41
|
+
locator_value = getattr(step, "locator_value", "")
|
|
42
|
+
if locator_value and str(locator_value).lower() != "global":
|
|
43
|
+
return f"{step.action}:{locator_value}"
|
|
44
|
+
return f"step_{index}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _workflow_step_to_action_data(step, index: int) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"name": _workflow_step_display_name(step, index),
|
|
50
|
+
"action": step.action,
|
|
51
|
+
"locator_type": step.locator_type,
|
|
52
|
+
"locator_value": step.locator_value,
|
|
53
|
+
"extra_value": step.extra_value,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_workflow_summary(args, workflow, **extra_fields) -> dict:
|
|
58
|
+
summary = {
|
|
59
|
+
"workflow_path": str(Path(args.workflow).resolve()),
|
|
60
|
+
"workflow_name": workflow.name or Path(args.workflow).stem,
|
|
61
|
+
"workflow_platform": workflow.platform or args.platform,
|
|
62
|
+
"resolved_vars": dict(workflow.vars),
|
|
63
|
+
"step_count": len([step for step in workflow.steps if step.enabled]),
|
|
64
|
+
}
|
|
65
|
+
summary.update(extra_fields)
|
|
66
|
+
return summary
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_workflow_plan_only_mode(
|
|
70
|
+
args,
|
|
71
|
+
output_script_path: str,
|
|
72
|
+
resume_context: dict,
|
|
73
|
+
) -> int:
|
|
74
|
+
reporter = _build_reporter(args, output_script_path, MODE_PLAN_ONLY)
|
|
75
|
+
final_status = "failed"
|
|
76
|
+
exit_code = 1
|
|
77
|
+
final_error = ""
|
|
78
|
+
_emit_run_started(reporter, args, output_script_path, MODE_PLAN_ONLY)
|
|
79
|
+
_apply_resume_summary(reporter, resume_context)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
workflow = _load_workflow_definition(args)
|
|
83
|
+
planned_steps = [
|
|
84
|
+
_workflow_step_display_name(step, index)
|
|
85
|
+
for index, step in enumerate(workflow.steps, start=1)
|
|
86
|
+
if step.enabled
|
|
87
|
+
]
|
|
88
|
+
plan = {
|
|
89
|
+
"current_state_summary": f"Workflow [{workflow.name or Path(args.workflow).stem}] preview",
|
|
90
|
+
"planned_steps": planned_steps,
|
|
91
|
+
"suggested_assertion": "",
|
|
92
|
+
"risks": [],
|
|
93
|
+
}
|
|
94
|
+
workflow_summary = _build_workflow_summary(args, workflow)
|
|
95
|
+
reporter.update_control_summary(
|
|
96
|
+
control_kind="workflow",
|
|
97
|
+
control_label=workflow_summary["workflow_name"],
|
|
98
|
+
source_ref=workflow_summary["workflow_path"],
|
|
99
|
+
step_count=workflow_summary["step_count"],
|
|
100
|
+
resolved_vars=workflow_summary["resolved_vars"],
|
|
101
|
+
)
|
|
102
|
+
reporter.emit_event(
|
|
103
|
+
"workflow_loaded",
|
|
104
|
+
workflow_name=workflow_summary["workflow_name"],
|
|
105
|
+
workflow_path=workflow_summary["workflow_path"],
|
|
106
|
+
step_count=workflow_summary["step_count"],
|
|
107
|
+
)
|
|
108
|
+
reporter.emit_event(
|
|
109
|
+
"plan_generated",
|
|
110
|
+
current_state_summary=plan["current_state_summary"],
|
|
111
|
+
planned_steps=planned_steps,
|
|
112
|
+
suggested_assertion="",
|
|
113
|
+
risks=[],
|
|
114
|
+
)
|
|
115
|
+
reporter.update_summary(plan_preview=plan, workflow_summary=workflow_summary)
|
|
116
|
+
|
|
117
|
+
log.info(f"🧭 [Workflow] Workflow name: {workflow_summary['workflow_name']}")
|
|
118
|
+
for index, step_name in enumerate(planned_steps, start=1):
|
|
119
|
+
log.info(f"🧭 [Workflow] Step {index}: {step_name}")
|
|
120
|
+
|
|
121
|
+
final_status = "success"
|
|
122
|
+
exit_code = 0
|
|
123
|
+
except Exception as e:
|
|
124
|
+
final_error = str(e)
|
|
125
|
+
reporter.emit_event("workflow_plan_failed", error=str(e))
|
|
126
|
+
log.error(f"❌ [Workflow] Plan generation failed: {e}")
|
|
127
|
+
finally:
|
|
128
|
+
reporter.finalize(
|
|
129
|
+
status=final_status,
|
|
130
|
+
exit_code=exit_code,
|
|
131
|
+
steps_executed=0 if final_error else 1,
|
|
132
|
+
last_error=final_error,
|
|
133
|
+
)
|
|
134
|
+
return exit_code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_workflow_dry_run_mode(
|
|
138
|
+
args,
|
|
139
|
+
output_script_path: str,
|
|
140
|
+
resume_context: dict,
|
|
141
|
+
) -> int:
|
|
142
|
+
reporter = _build_reporter(args, output_script_path, MODE_DRY_RUN)
|
|
143
|
+
final_status = "failed"
|
|
144
|
+
exit_code = 1
|
|
145
|
+
final_error = ""
|
|
146
|
+
adapter = None
|
|
147
|
+
preview_steps = []
|
|
148
|
+
_emit_run_started(reporter, args, output_script_path, MODE_DRY_RUN)
|
|
149
|
+
_apply_resume_summary(reporter, resume_context)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
workflow = _load_workflow_definition(args)
|
|
153
|
+
workflow_summary = _build_workflow_summary(args, workflow)
|
|
154
|
+
reporter.update_control_summary(
|
|
155
|
+
control_kind="workflow",
|
|
156
|
+
control_label=workflow_summary["workflow_name"],
|
|
157
|
+
source_ref=workflow_summary["workflow_path"],
|
|
158
|
+
step_count=workflow_summary["step_count"],
|
|
159
|
+
resolved_vars=workflow_summary["resolved_vars"],
|
|
160
|
+
)
|
|
161
|
+
reporter.emit_event(
|
|
162
|
+
"workflow_loaded",
|
|
163
|
+
workflow_name=workflow_summary["workflow_name"],
|
|
164
|
+
workflow_path=workflow_summary["workflow_path"],
|
|
165
|
+
step_count=workflow_summary["step_count"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
adapter = _connect_adapter(args, reporter)
|
|
169
|
+
unresolved_steps = 0
|
|
170
|
+
for index, step in enumerate(workflow.steps, start=1):
|
|
171
|
+
if not step.enabled:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
action_data = _workflow_step_to_action_data(step, index)
|
|
175
|
+
resolution = _preview_action_resolution(
|
|
176
|
+
adapter.driver, args.platform, action_data
|
|
177
|
+
)
|
|
178
|
+
resolution_hint = _build_resolution_hint(args, action_data, resolution)
|
|
179
|
+
preview = {
|
|
180
|
+
"step": index,
|
|
181
|
+
"name": action_data["name"],
|
|
182
|
+
"action": action_data["action"],
|
|
183
|
+
"locator_type": action_data["locator_type"],
|
|
184
|
+
"locator_value": action_data["locator_value"],
|
|
185
|
+
"extra_value": action_data["extra_value"],
|
|
186
|
+
"resolvable": resolution.get("resolvable", False),
|
|
187
|
+
"resolution_error": resolution.get("resolution_error", ""),
|
|
188
|
+
"resolution_hint": resolution_hint,
|
|
189
|
+
}
|
|
190
|
+
if not preview["resolvable"]:
|
|
191
|
+
unresolved_steps += 1
|
|
192
|
+
preview_steps.append(preview)
|
|
193
|
+
reporter.emit_event("workflow_step_preview", **preview)
|
|
194
|
+
|
|
195
|
+
workflow_summary = _build_workflow_summary(
|
|
196
|
+
args,
|
|
197
|
+
workflow,
|
|
198
|
+
preview_steps=preview_steps,
|
|
199
|
+
unresolved_steps=unresolved_steps,
|
|
200
|
+
)
|
|
201
|
+
reporter.update_control_summary(
|
|
202
|
+
preview_steps=preview_steps,
|
|
203
|
+
unresolved_steps=unresolved_steps,
|
|
204
|
+
)
|
|
205
|
+
reporter.update_summary(
|
|
206
|
+
workflow_summary=workflow_summary,
|
|
207
|
+
dry_run_preview={
|
|
208
|
+
"workflow": True,
|
|
209
|
+
"step_count": workflow_summary["step_count"],
|
|
210
|
+
"unresolved_steps": unresolved_steps,
|
|
211
|
+
"preview_steps": preview_steps,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if unresolved_steps:
|
|
216
|
+
final_error = f"{unresolved_steps} workflow step(s) could not be resolved"
|
|
217
|
+
exit_code = 1
|
|
218
|
+
else:
|
|
219
|
+
final_status = "success"
|
|
220
|
+
exit_code = 0
|
|
221
|
+
except Exception as e:
|
|
222
|
+
final_error = str(e)
|
|
223
|
+
reporter.emit_event("workflow_dry_run_failed", error=str(e))
|
|
224
|
+
log.error(f"❌ [Workflow] Dry-run failed: {e}")
|
|
225
|
+
finally:
|
|
226
|
+
reporter.finalize(
|
|
227
|
+
status=final_status,
|
|
228
|
+
exit_code=exit_code,
|
|
229
|
+
steps_executed=len(preview_steps),
|
|
230
|
+
last_error=final_error,
|
|
231
|
+
)
|
|
232
|
+
if adapter:
|
|
233
|
+
try:
|
|
234
|
+
adapter.teardown()
|
|
235
|
+
except Exception as e:
|
|
236
|
+
log.warning(f"⚠️ [Warning] Cleanup failed: {e}")
|
|
237
|
+
return exit_code
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_workflow_default_mode(
|
|
241
|
+
args,
|
|
242
|
+
output_script_path: str,
|
|
243
|
+
resume_context: dict,
|
|
244
|
+
) -> int:
|
|
245
|
+
adapter = None
|
|
246
|
+
reporter = _build_reporter(args, output_script_path, MODE_RUN)
|
|
247
|
+
exit_code = 1
|
|
248
|
+
final_status = "failed"
|
|
249
|
+
final_error = ""
|
|
250
|
+
steps_executed = 0
|
|
251
|
+
_emit_run_started(reporter, args, output_script_path, MODE_RUN)
|
|
252
|
+
_apply_resume_summary(reporter, resume_context)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
workflow = _load_workflow_definition(args)
|
|
256
|
+
workflow_summary = _build_workflow_summary(
|
|
257
|
+
args, workflow, executed_steps=0
|
|
258
|
+
)
|
|
259
|
+
reporter.update_control_summary(
|
|
260
|
+
control_kind="workflow",
|
|
261
|
+
control_label=workflow_summary["workflow_name"],
|
|
262
|
+
source_ref=workflow_summary["workflow_path"],
|
|
263
|
+
step_count=workflow_summary["step_count"],
|
|
264
|
+
resolved_vars=workflow_summary["resolved_vars"],
|
|
265
|
+
)
|
|
266
|
+
reporter.emit_event(
|
|
267
|
+
"workflow_loaded",
|
|
268
|
+
workflow_name=workflow_summary["workflow_name"],
|
|
269
|
+
workflow_path=workflow_summary["workflow_path"],
|
|
270
|
+
step_count=workflow_summary["step_count"],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
adapter = _connect_adapter(args, reporter)
|
|
275
|
+
device = adapter.driver
|
|
276
|
+
except Exception as e:
|
|
277
|
+
final_error = str(e)
|
|
278
|
+
reporter.emit_event("startup_failed", platform=args.platform, error=str(e))
|
|
279
|
+
log.error(f"❌ [Error] {args.platform} connection failed: {e}")
|
|
280
|
+
return 1
|
|
281
|
+
|
|
282
|
+
_ensure_history_manager()
|
|
283
|
+
_ensure_executor_runtime()
|
|
284
|
+
header = get_initial_header(label=str(getattr(workflow, "name", "")).strip() or None)
|
|
285
|
+
history_manager = _shared.StepHistoryManager(initial_content=header)
|
|
286
|
+
save_to_disk(output_script_path, header)
|
|
287
|
+
executor = _shared.UIExecutor(device, platform=args.platform)
|
|
288
|
+
|
|
289
|
+
executed_steps = 0
|
|
290
|
+
for index, step in enumerate(workflow.steps, start=1):
|
|
291
|
+
if not step.enabled:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
steps_executed = index
|
|
295
|
+
action_data = _workflow_step_to_action_data(step, index)
|
|
296
|
+
reporter.emit_event(
|
|
297
|
+
"step_started",
|
|
298
|
+
step=index,
|
|
299
|
+
source="workflow",
|
|
300
|
+
step_name=action_data["name"],
|
|
301
|
+
)
|
|
302
|
+
result = executor.execute_and_record(action_data)
|
|
303
|
+
if not result.get("success"):
|
|
304
|
+
final_error = f"Workflow step failed: {action_data['name']}"
|
|
305
|
+
reporter.emit_event(
|
|
306
|
+
"action_executed",
|
|
307
|
+
step=index,
|
|
308
|
+
success=False,
|
|
309
|
+
action_description=action_data["name"],
|
|
310
|
+
)
|
|
311
|
+
log.error(f"❌ [Workflow] Step failed: {action_data['name']}")
|
|
312
|
+
return 1
|
|
313
|
+
|
|
314
|
+
history_manager.add_step(
|
|
315
|
+
result["code_lines"], result["action_description"]
|
|
316
|
+
)
|
|
317
|
+
save_to_disk(output_script_path, history_manager.get_current_file_content())
|
|
318
|
+
reporter.emit_event(
|
|
319
|
+
"action_executed",
|
|
320
|
+
step=index,
|
|
321
|
+
success=True,
|
|
322
|
+
action_description=result["action_description"],
|
|
323
|
+
)
|
|
324
|
+
executed_steps += 1
|
|
325
|
+
|
|
326
|
+
reporter.update_summary(
|
|
327
|
+
workflow_summary=_build_workflow_summary(
|
|
328
|
+
args, workflow, executed_steps=executed_steps
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
reporter.update_control_summary(executed_steps=executed_steps)
|
|
332
|
+
final_status = "success"
|
|
333
|
+
exit_code = 0
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
final_error = str(e)
|
|
338
|
+
reporter.emit_event("workflow_run_failed", error=str(e))
|
|
339
|
+
log.error(f"❌ [Workflow] Execution failed: {e}")
|
|
340
|
+
return 1
|
|
341
|
+
|
|
342
|
+
finally:
|
|
343
|
+
reporter.finalize(
|
|
344
|
+
status=final_status,
|
|
345
|
+
exit_code=exit_code,
|
|
346
|
+
steps_executed=steps_executed,
|
|
347
|
+
last_error=final_error,
|
|
348
|
+
)
|
|
349
|
+
log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
|
|
350
|
+
if adapter:
|
|
351
|
+
try:
|
|
352
|
+
adapter.teardown()
|
|
353
|
+
except Exception as e:
|
|
354
|
+
log.warning(f"⚠️ [Warning] Cleanup failed: {e}")
|
cli/parser.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""CLI argument parser and validation."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from cli._version import __version__
|
|
6
|
+
from common.runtime_modes import resolve_execution_mode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="screenforge",
|
|
12
|
+
description="AI-driven cross-platform UI automation engine",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("--version", action="version", version=f"screenforge {__version__}")
|
|
15
|
+
parser.add_argument("--goal", type=str, default="", help="High-level test goal (natural language)")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--context", type=str, default="", help="Path to PRD or test case specification file"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--env",
|
|
21
|
+
type=str,
|
|
22
|
+
default="dev",
|
|
23
|
+
choices=["dev", "prod", "us_dev", "us_prod"],
|
|
24
|
+
help="Target environment (default: dev)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--max_steps", type=int, default=15, help="Max autonomous exploration steps")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--max_retries",
|
|
29
|
+
type=int,
|
|
30
|
+
default=3,
|
|
31
|
+
help="Max retries per step before circuit breaker triggers",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--output", type=str, default="", help="Output path for generated pytest script"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--platform",
|
|
38
|
+
type=str,
|
|
39
|
+
default="web",
|
|
40
|
+
choices=["android", "ios", "web"],
|
|
41
|
+
help="Target platform (default: web)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--vision", action="store_true", help="Enable multimodal (vision) mode"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--json",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Emit structured JSON events to stdout for Agent integration",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--doctor", action="store_true", help="Run environment diagnostics only"
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--plan-only",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Generate execution plan without performing actions",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--dry-run",
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="Simulate execution and output would-execute results",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--resume-run-id",
|
|
66
|
+
type=str,
|
|
67
|
+
default="",
|
|
68
|
+
help="Resume from a previous run (reads report/runs/<run_id>/)",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--workflow",
|
|
72
|
+
type=str,
|
|
73
|
+
default="",
|
|
74
|
+
help="Path to structured workflow YAML file",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--workflow-var",
|
|
78
|
+
action="append",
|
|
79
|
+
default=[],
|
|
80
|
+
help="Override workflow variable (KEY=VALUE, repeatable)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--action",
|
|
84
|
+
type=str,
|
|
85
|
+
default="",
|
|
86
|
+
help="Execute a single immediate action (click, input, goto, etc.)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--action-name",
|
|
90
|
+
type=str,
|
|
91
|
+
default="",
|
|
92
|
+
help="Human-readable name for the action (for reporting)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--locator-type",
|
|
96
|
+
type=str,
|
|
97
|
+
default="",
|
|
98
|
+
help="Locator strategy: css, text, resourceId, description, ref",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--locator-value",
|
|
102
|
+
type=str,
|
|
103
|
+
default="",
|
|
104
|
+
help="Locator value to find the target element",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--extra-value",
|
|
108
|
+
type=str,
|
|
109
|
+
default="",
|
|
110
|
+
help="Extra value for action (input text, key name, expected text, URL)",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--capabilities",
|
|
114
|
+
action="store_true",
|
|
115
|
+
help="Output machine-readable capability snapshot",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--tool-request",
|
|
119
|
+
type=str,
|
|
120
|
+
default="",
|
|
121
|
+
help="Read request from JSON file and return unified JSON response",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--tool-stdin",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="Read request from stdin and return unified JSON response",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--mcp-server",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="Start MCP server (stdio transport) for Agent integration",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--init",
|
|
135
|
+
action="store_true",
|
|
136
|
+
help="Interactive first-time setup wizard",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--demo",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Run simulated demo (no API key needed)",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--playground",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help="Start Playground web UI (live screenshot viewer + action history)",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--playground-port",
|
|
150
|
+
type=int,
|
|
151
|
+
default=7860,
|
|
152
|
+
help="Playground server port (default: 7860)",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--device-url",
|
|
156
|
+
type=str,
|
|
157
|
+
default="",
|
|
158
|
+
help="Device connection URL (overrides WDA_URL for iOS, ignored for Android)",
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--device-serial",
|
|
162
|
+
type=str,
|
|
163
|
+
default="",
|
|
164
|
+
help="Device serial number (overrides ANDROID_SERIAL for Android, IOS_DEVICE_UDID for iOS)",
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--session-id",
|
|
168
|
+
type=str,
|
|
169
|
+
default="",
|
|
170
|
+
help="Session ID for multi-step recording (actions with same ID share one test file and recording)",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--session-end",
|
|
174
|
+
type=str,
|
|
175
|
+
default="",
|
|
176
|
+
help="End a session by ID (stops recording, outputs final test file path)",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--web-stop",
|
|
180
|
+
action="store_true",
|
|
181
|
+
help="Terminate the persistent Chromium browser left running by web runs",
|
|
182
|
+
)
|
|
183
|
+
return parser
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def validate_cli_args(args: argparse.Namespace) -> None:
|
|
187
|
+
resolve_execution_mode(
|
|
188
|
+
doctor=args.doctor,
|
|
189
|
+
plan_only=args.plan_only,
|
|
190
|
+
dry_run=args.dry_run,
|
|
191
|
+
)
|
|
192
|
+
has_goal = bool(str(args.goal).strip())
|
|
193
|
+
has_workflow = bool(str(getattr(args, "workflow", "")).strip())
|
|
194
|
+
has_action = bool(str(getattr(args, "action", "")).strip())
|
|
195
|
+
has_capabilities = bool(getattr(args, "capabilities", False))
|
|
196
|
+
has_tool_request = bool(str(getattr(args, "tool_request", "")).strip())
|
|
197
|
+
has_tool_stdin = bool(getattr(args, "tool_stdin", False))
|
|
198
|
+
has_mcp_server = bool(getattr(args, "mcp_server", False))
|
|
199
|
+
if has_tool_request and has_tool_stdin:
|
|
200
|
+
raise ValueError("--tool-request and --tool-stdin are mutually exclusive")
|
|
201
|
+
if has_mcp_server:
|
|
202
|
+
if any(
|
|
203
|
+
[
|
|
204
|
+
has_capabilities,
|
|
205
|
+
has_tool_request,
|
|
206
|
+
has_tool_stdin,
|
|
207
|
+
args.doctor,
|
|
208
|
+
args.plan_only,
|
|
209
|
+
args.dry_run,
|
|
210
|
+
has_goal,
|
|
211
|
+
has_workflow,
|
|
212
|
+
has_action,
|
|
213
|
+
bool(str(getattr(args, "resume_run_id", "")).strip()),
|
|
214
|
+
]
|
|
215
|
+
):
|
|
216
|
+
raise ValueError("--mcp-server cannot be combined with other execution flags")
|
|
217
|
+
return
|
|
218
|
+
if has_tool_request:
|
|
219
|
+
if any(
|
|
220
|
+
[
|
|
221
|
+
has_capabilities,
|
|
222
|
+
has_tool_stdin,
|
|
223
|
+
has_mcp_server,
|
|
224
|
+
args.doctor,
|
|
225
|
+
args.plan_only,
|
|
226
|
+
args.dry_run,
|
|
227
|
+
has_goal,
|
|
228
|
+
has_workflow,
|
|
229
|
+
has_action,
|
|
230
|
+
bool(str(getattr(args, "resume_run_id", "")).strip()),
|
|
231
|
+
]
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("--tool-request cannot be combined with other execution flags")
|
|
234
|
+
return
|
|
235
|
+
if has_tool_stdin:
|
|
236
|
+
if any(
|
|
237
|
+
[
|
|
238
|
+
has_capabilities,
|
|
239
|
+
has_mcp_server,
|
|
240
|
+
args.doctor,
|
|
241
|
+
args.plan_only,
|
|
242
|
+
args.dry_run,
|
|
243
|
+
has_goal,
|
|
244
|
+
has_workflow,
|
|
245
|
+
has_action,
|
|
246
|
+
bool(str(getattr(args, "resume_run_id", "")).strip()),
|
|
247
|
+
]
|
|
248
|
+
):
|
|
249
|
+
raise ValueError("--tool-stdin cannot be combined with other execution flags")
|
|
250
|
+
return
|
|
251
|
+
if has_capabilities:
|
|
252
|
+
if any(
|
|
253
|
+
[
|
|
254
|
+
args.doctor,
|
|
255
|
+
args.plan_only,
|
|
256
|
+
args.dry_run,
|
|
257
|
+
has_goal,
|
|
258
|
+
has_workflow,
|
|
259
|
+
has_action,
|
|
260
|
+
has_tool_request,
|
|
261
|
+
has_tool_stdin,
|
|
262
|
+
has_mcp_server,
|
|
263
|
+
]
|
|
264
|
+
):
|
|
265
|
+
raise ValueError("--capabilities cannot be combined with other execution flags")
|
|
266
|
+
return
|
|
267
|
+
if has_goal and has_workflow:
|
|
268
|
+
raise ValueError("--goal and --workflow are mutually exclusive")
|
|
269
|
+
if has_action and (has_goal or has_workflow):
|
|
270
|
+
raise ValueError("--action cannot be combined with --goal or --workflow")
|
|
271
|
+
has_demo = bool(getattr(args, "demo", False))
|
|
272
|
+
has_init = bool(getattr(args, "init", False))
|
|
273
|
+
if has_demo or has_init:
|
|
274
|
+
return
|
|
275
|
+
has_session_end = bool(str(getattr(args, "session_end", "")).strip())
|
|
276
|
+
if has_session_end:
|
|
277
|
+
return
|
|
278
|
+
if bool(getattr(args, "web_stop", False)):
|
|
279
|
+
return
|
|
280
|
+
if not args.doctor and not has_goal and not has_workflow and not has_action:
|
|
281
|
+
raise ValueError("Must provide --goal, --workflow, or --action (use --doctor for diagnostics)")
|
|
282
|
+
if has_workflow:
|
|
283
|
+
for item in getattr(args, "workflow_var", []) or []:
|
|
284
|
+
if "=" not in str(item):
|
|
285
|
+
raise ValueError("--workflow-var format must be KEY=VALUE")
|
|
286
|
+
if has_action:
|
|
287
|
+
from common.capabilities import (
|
|
288
|
+
ACTIONS_REQUIRING_EXTRA_VALUE,
|
|
289
|
+
GLOBAL_ACTIONS,
|
|
290
|
+
SUPPORTED_ACTIONS,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
action = str(args.action).strip()
|
|
294
|
+
if action not in SUPPORTED_ACTIONS:
|
|
295
|
+
valid = ", ".join(sorted(SUPPORTED_ACTIONS))
|
|
296
|
+
raise ValueError(f"Unsupported action: {action}. Valid actions: {valid}")
|
|
297
|
+
if action not in GLOBAL_ACTIONS:
|
|
298
|
+
if not str(getattr(args, "locator_type", "")).strip() or not str(
|
|
299
|
+
getattr(args, "locator_value", "")
|
|
300
|
+
).strip():
|
|
301
|
+
raise ValueError("Element actions require --locator-type and --locator-value")
|
|
302
|
+
if action in ACTIONS_REQUIRING_EXTRA_VALUE and not str(
|
|
303
|
+
getattr(args, "extra_value", "")
|
|
304
|
+
).strip():
|
|
305
|
+
raise ValueError(f"Action '{action}' requires --extra-value")
|