@qubiqlabs/mobiflow 0.9.0 → 1.0.0
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.
- package/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- package/src/mobiflow/suite.py +263 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
"""Maestro CLI adapter: status, devices, YAML gen, live run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from mobiflow.llm import (
|
|
18
|
+
ChatUsage,
|
|
19
|
+
extract_fenced_files,
|
|
20
|
+
extract_yaml_fence,
|
|
21
|
+
invoke_chat_text,
|
|
22
|
+
merge_usage_list,
|
|
23
|
+
profile_to_llm_config,
|
|
24
|
+
)
|
|
25
|
+
from mobiflow.llm_catalog import ModelEntry
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
ProgressFn = Optional[Callable[[str], None]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FlowBundle:
|
|
34
|
+
"""Maestro flow YAML plus optional companion JavaScript files."""
|
|
35
|
+
|
|
36
|
+
flow_yaml: str
|
|
37
|
+
scripts: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
usage: ChatUsage = field(default_factory=ChatUsage)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def has_js(self) -> bool:
|
|
42
|
+
return bool(self.scripts) or bool(
|
|
43
|
+
re.search(r"(?m)^\s*-\s*(evalScript|runScript)\b", self.flow_yaml or "")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_KNOWN_APP_IDS = {
|
|
47
|
+
"wikipedia": {"android": "org.wikipedia", "ios": "org.wikimedia.wikipedia"},
|
|
48
|
+
"settings": {"android": "com.android.settings", "ios": "com.apple.Preferences"},
|
|
49
|
+
"chrome": {"android": "com.android.chrome", "ios": "com.google.chrome.ios"},
|
|
50
|
+
"safari": {"android": "com.android.chrome", "ios": "com.apple.mobilesafari"},
|
|
51
|
+
# FOSS sample apps (install yourself — see docs/SAMPLE_APPS.md)
|
|
52
|
+
"joplin": {"android": "net.cozic.joplin", "ios": "net.cozic.joplin"},
|
|
53
|
+
"bitwarden": {"android": "com.x8bit.bitwarden", "ios": "com.8bit.bitwarden"},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_maestro_binary() -> str | None:
|
|
58
|
+
which = shutil.which("maestro")
|
|
59
|
+
if which:
|
|
60
|
+
return which
|
|
61
|
+
home = Path.home() / ".maestro" / "bin" / "maestro"
|
|
62
|
+
if home.is_file() and os.access(home, os.X_OK):
|
|
63
|
+
return str(home)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_java_home() -> str | None:
|
|
68
|
+
jh = os.environ.get("JAVA_HOME")
|
|
69
|
+
if jh and Path(jh).is_dir():
|
|
70
|
+
return jh
|
|
71
|
+
# Common macOS Homebrew / system locations
|
|
72
|
+
for candidate in (
|
|
73
|
+
"/opt/homebrew/opt/openjdk",
|
|
74
|
+
"/usr/local/opt/openjdk",
|
|
75
|
+
):
|
|
76
|
+
if Path(candidate).is_dir():
|
|
77
|
+
return candidate
|
|
78
|
+
# Windows: JAVA_HOME usually set by installer; also check Program Files
|
|
79
|
+
if platform_system() == "Windows":
|
|
80
|
+
for base in (
|
|
81
|
+
Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Java",
|
|
82
|
+
Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Microsoft",
|
|
83
|
+
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Eclipse Adoptium",
|
|
84
|
+
):
|
|
85
|
+
if base.is_dir():
|
|
86
|
+
# pick first jdk-* / jre-* child
|
|
87
|
+
for child in sorted(base.glob("jdk*")) + sorted(base.glob("jre*")):
|
|
88
|
+
if child.is_dir():
|
|
89
|
+
return str(child)
|
|
90
|
+
return jh
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def platform_system() -> str:
|
|
94
|
+
import platform
|
|
95
|
+
|
|
96
|
+
return platform.system()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _maestro_env() -> dict[str, str]:
|
|
100
|
+
env = dict(os.environ)
|
|
101
|
+
env.setdefault("MAESTRO_CLI_NO_ANALYTICS", "1")
|
|
102
|
+
jh = resolve_java_home()
|
|
103
|
+
if jh:
|
|
104
|
+
env.setdefault("JAVA_HOME", jh)
|
|
105
|
+
# Maestro's JVM uses ANDROID_HOME to find adb. On Windows it is often unset
|
|
106
|
+
# even when `adb` works in PATH — then `--device emulator-5554` fails.
|
|
107
|
+
from mobiflow.devices import _sdk_roots, resolve_adb
|
|
108
|
+
|
|
109
|
+
sdk = env.get("ANDROID_HOME") or env.get("ANDROID_SDK_ROOT") or ""
|
|
110
|
+
if not sdk:
|
|
111
|
+
for root in _sdk_roots():
|
|
112
|
+
if (root / "platform-tools").is_dir():
|
|
113
|
+
sdk = str(root)
|
|
114
|
+
break
|
|
115
|
+
if sdk:
|
|
116
|
+
env.setdefault("ANDROID_HOME", sdk)
|
|
117
|
+
env.setdefault("ANDROID_SDK_ROOT", sdk)
|
|
118
|
+
plat = str(Path(sdk) / "platform-tools")
|
|
119
|
+
env["PATH"] = plat + os.pathsep + env.get("PATH", "")
|
|
120
|
+
adb = resolve_adb()
|
|
121
|
+
if adb:
|
|
122
|
+
env["PATH"] = str(Path(adb).parent) + os.pathsep + env.get("PATH", "")
|
|
123
|
+
maestro_bin = resolve_maestro_binary()
|
|
124
|
+
if maestro_bin:
|
|
125
|
+
bin_dir = str(Path(maestro_bin).parent)
|
|
126
|
+
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
|
127
|
+
return env
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _run_cmd(
|
|
131
|
+
args: list[str],
|
|
132
|
+
*,
|
|
133
|
+
timeout: float = 120.0,
|
|
134
|
+
cwd: str | None = None,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
try:
|
|
137
|
+
proc = await asyncio.create_subprocess_exec(
|
|
138
|
+
*args,
|
|
139
|
+
stdout=asyncio.subprocess.PIPE,
|
|
140
|
+
stderr=asyncio.subprocess.PIPE,
|
|
141
|
+
cwd=cwd,
|
|
142
|
+
env=_maestro_env(),
|
|
143
|
+
)
|
|
144
|
+
except FileNotFoundError as e:
|
|
145
|
+
return {
|
|
146
|
+
"ok": False,
|
|
147
|
+
"returncode": -1,
|
|
148
|
+
"stdout": "",
|
|
149
|
+
"stderr": str(e),
|
|
150
|
+
"error": "executable_not_found",
|
|
151
|
+
}
|
|
152
|
+
try:
|
|
153
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
154
|
+
except TimeoutError:
|
|
155
|
+
try:
|
|
156
|
+
proc.kill()
|
|
157
|
+
except ProcessLookupError:
|
|
158
|
+
pass
|
|
159
|
+
return {
|
|
160
|
+
"ok": False,
|
|
161
|
+
"returncode": -1,
|
|
162
|
+
"stdout": "",
|
|
163
|
+
"stderr": f"Timed out after {timeout}s",
|
|
164
|
+
"error": "timeout",
|
|
165
|
+
}
|
|
166
|
+
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
|
|
167
|
+
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
|
|
168
|
+
code = proc.returncode if proc.returncode is not None else -1
|
|
169
|
+
return {
|
|
170
|
+
"ok": code == 0,
|
|
171
|
+
"returncode": code,
|
|
172
|
+
"stdout": stdout,
|
|
173
|
+
"stderr": stderr,
|
|
174
|
+
"error": None if code == 0 else "nonzero_exit",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def list_devices() -> list[dict[str, str]]:
|
|
179
|
+
"""Online devices only (adb + booted iOS sims)."""
|
|
180
|
+
from mobiflow.devices import list_connected_devices
|
|
181
|
+
|
|
182
|
+
return await list_connected_devices()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def list_device_targets() -> list[dict[str, str]]:
|
|
186
|
+
"""Online + startable AVDs / iOS simulators."""
|
|
187
|
+
from mobiflow.devices import list_all_targets
|
|
188
|
+
|
|
189
|
+
return await list_all_targets()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def get_maestro_version() -> str | None:
|
|
193
|
+
binary = resolve_maestro_binary()
|
|
194
|
+
if not binary:
|
|
195
|
+
return None
|
|
196
|
+
result = await _run_cmd([binary, "--version"], timeout=20.0)
|
|
197
|
+
text = "\n".join(
|
|
198
|
+
[(result.get("stdout") or "").strip(), (result.get("stderr") or "").strip()]
|
|
199
|
+
)
|
|
200
|
+
for line in text.splitlines():
|
|
201
|
+
m = re.search(r"\b(\d+\.\d+(?:\.\d+)?)\b", line)
|
|
202
|
+
if m:
|
|
203
|
+
return m.group(1)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def get_status() -> dict[str, Any]:
|
|
208
|
+
from mobiflow.devices import (
|
|
209
|
+
host_capabilities,
|
|
210
|
+
list_all_targets,
|
|
211
|
+
list_connected_devices,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
binary = resolve_maestro_binary()
|
|
215
|
+
installed = binary is not None
|
|
216
|
+
java_home = resolve_java_home()
|
|
217
|
+
version = await get_maestro_version() if installed else None
|
|
218
|
+
devices = await list_connected_devices()
|
|
219
|
+
targets = await list_all_targets()
|
|
220
|
+
caps = host_capabilities()
|
|
221
|
+
startable = [t for t in targets if t.get("startable") == "true"]
|
|
222
|
+
ready = bool(installed and java_home and (devices or startable))
|
|
223
|
+
if not installed:
|
|
224
|
+
message = "Maestro CLI not found. Install: curl -Ls https://get.maestro.mobile.dev | bash"
|
|
225
|
+
elif not java_home:
|
|
226
|
+
message = "JAVA_HOME not set — Maestro needs a JDK."
|
|
227
|
+
elif devices:
|
|
228
|
+
message = f"Maestro {version or '?'} ready · {len(devices)} device(s) online"
|
|
229
|
+
elif startable:
|
|
230
|
+
message = (
|
|
231
|
+
f"Maestro {version or '?'} installed · no device online, "
|
|
232
|
+
f"but {len(startable)} emulator/simulator(s) can be auto-started"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
message = (
|
|
236
|
+
f"Maestro {version or '?'} installed, but no devices/emulators found. "
|
|
237
|
+
"Install Android Studio (AVD) and/or Xcode (macOS)."
|
|
238
|
+
)
|
|
239
|
+
return {
|
|
240
|
+
"installed": installed,
|
|
241
|
+
"binary": binary,
|
|
242
|
+
"java_home": java_home,
|
|
243
|
+
"version": version,
|
|
244
|
+
"devices": devices,
|
|
245
|
+
"targets": targets,
|
|
246
|
+
"device_count": len(devices),
|
|
247
|
+
"startable_count": len(startable),
|
|
248
|
+
"host": caps,
|
|
249
|
+
"ready": ready,
|
|
250
|
+
"message": message,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def infer_platform(device_id: str, fallback: str = "android") -> str:
|
|
255
|
+
did = (device_id or "").strip()
|
|
256
|
+
if re.fullmatch(r"[0-9A-Fa-f-]{36}", did):
|
|
257
|
+
return "ios"
|
|
258
|
+
if did.startswith("emulator-") or ":" in did:
|
|
259
|
+
return "android"
|
|
260
|
+
return (fallback or "android").lower()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_app_id(app_id: str, platform: str, goal: str = "") -> str:
|
|
264
|
+
if (app_id or "").strip():
|
|
265
|
+
return app_id.strip()
|
|
266
|
+
g = (goal or "").lower()
|
|
267
|
+
plat = (platform or "android").lower()
|
|
268
|
+
for key, mapping in _KNOWN_APP_IDS.items():
|
|
269
|
+
if key in g:
|
|
270
|
+
return mapping.get(plat) or mapping["android"]
|
|
271
|
+
return mapping_default(plat)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def mapping_default(platform: str) -> str:
|
|
275
|
+
return (
|
|
276
|
+
"com.apple.Preferences"
|
|
277
|
+
if platform == "ios"
|
|
278
|
+
else "com.android.settings"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def looks_like_maestro_yaml(text: str) -> bool:
|
|
283
|
+
t = (text or "").strip()
|
|
284
|
+
if not t:
|
|
285
|
+
return False
|
|
286
|
+
if re.search(r"(?m)^appId:\s*\S+", t) and "---" in t:
|
|
287
|
+
return True
|
|
288
|
+
if re.search(r"(?m)^\s*-\s*(launchApp|tapOn|assertVisible|openLink)\b", t):
|
|
289
|
+
return True
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def ensure_flow_yaml(yaml_text: str, app_id: str) -> str:
|
|
294
|
+
text = (yaml_text or "").strip()
|
|
295
|
+
if not text:
|
|
296
|
+
aid = app_id or "com.android.settings"
|
|
297
|
+
return f"appId: {aid}\nname: Generated mobile flow\n---\n- launchApp\n"
|
|
298
|
+
if not re.search(r"(?m)^appId:\s*", text):
|
|
299
|
+
aid = app_id or "com.android.settings"
|
|
300
|
+
if "---" in text:
|
|
301
|
+
return f"appId: {aid}\n---\n" + text.split("---", 1)[-1].lstrip()
|
|
302
|
+
return f"appId: {aid}\nname: Generated mobile flow\n---\n{text}"
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def ensure_stop_app(yaml_text: str) -> str:
|
|
307
|
+
if re.search(r"(?m)^\s*-\s*stopApp\b", yaml_text or ""):
|
|
308
|
+
return yaml_text
|
|
309
|
+
return (yaml_text or "").rstrip() + "\n- stopApp\n"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_MAESTRO_SYSTEM_YAML = """You are a Maestro mobile test engineer.
|
|
313
|
+
Emit ONLY valid Maestro flow YAML (appId config above ---, commands below).
|
|
314
|
+
Rules:
|
|
315
|
+
1) Cover EVERY goal step — launch, navigate, assert, dismiss onboarding when needed.
|
|
316
|
+
2) Prefer idiomatic Maestro commands (see docs.maestro.dev):
|
|
317
|
+
launchApp, stopApp, clearState, clearKeychain, openLink,
|
|
318
|
+
tapOn, doubleTapOn, longPressOn, inputText, eraseText, pressKey, hideKeyboard,
|
|
319
|
+
copyTextFrom, pasteText, swipe, scroll, scrollUntilVisible, extendedWaitUntil,
|
|
320
|
+
assertVisible, assertNotVisible, assertTrue, waitForAnimationToEnd,
|
|
321
|
+
takeScreenshot, setLocation, runFlow (subflows), runScript / evalScript (JS projects only).
|
|
322
|
+
3) Prefer selectors from exploration results / view hierarchy when provided;
|
|
323
|
+
else stable visible text / accessibility ids.
|
|
324
|
+
4) When exploration results include a grounded plan, follow that plan closely.
|
|
325
|
+
5) Reuse: extract repeated sequences into nested flows and call with runFlow.
|
|
326
|
+
Use onFlowStart / onFlowComplete hooks for setup/teardown when helpful.
|
|
327
|
+
6) iOS Settings: com.apple.Preferences. Android Settings: com.android.settings.
|
|
328
|
+
7) Mobile web (https://): openLink + Safari/Chrome appId. Never emit Playwright/Appium.
|
|
329
|
+
8) Known apps — use these appIds when the goal names them:
|
|
330
|
+
Wikipedia: org.wikipedia (Android) / org.wikimedia.wikipedia (iOS).
|
|
331
|
+
Joplin: net.cozic.joplin (Android + iOS).
|
|
332
|
+
Bitwarden: com.x8bit.bitwarden (Android) / com.8bit.bitwarden (iOS).
|
|
333
|
+
9) After launchApp, optionally dismiss Skip/Next/Continue/Allow/Not now.
|
|
334
|
+
9b) Always end happy-path flows with assertVisible (goal evidence); prefer known selectors.
|
|
335
|
+
9c) Never use maestro.visible(...) / maestro.isVisible — those APIs do not exist.
|
|
336
|
+
Prefer assertVisible / assertNotVisible. For OR checks, use separate optional
|
|
337
|
+
assertVisible / extendedWaitUntil steps, or one assertVisible with a regex
|
|
338
|
+
like "General|Accessibility". Do not invent assertTrue JS helpers for visibility.
|
|
339
|
+
10) End the flow with stopApp.
|
|
340
|
+
11) No markdown prose outside a ```yaml fence.
|
|
341
|
+
12) Do NOT use evalScript/runScript — YAML commands only for this project."""
|
|
342
|
+
|
|
343
|
+
_MAESTRO_SYSTEM_JS = """You are a Maestro mobile test engineer with JavaScript support enabled.
|
|
344
|
+
Emit a Maestro flow YAML and, when useful, companion JavaScript files.
|
|
345
|
+
|
|
346
|
+
Rules:
|
|
347
|
+
1) Primary artifact: valid Maestro YAML (appId above ---, commands below).
|
|
348
|
+
2) UI commands (docs.maestro.dev): launchApp, stopApp, clearState, clearKeychain, openLink,
|
|
349
|
+
tapOn, doubleTapOn, longPressOn, inputText, eraseText, pressKey, hideKeyboard,
|
|
350
|
+
copyTextFrom, pasteText, swipe, scroll, scrollUntilVisible, extendedWaitUntil,
|
|
351
|
+
assertVisible, assertNotVisible, assertTrue, waitForAnimationToEnd, takeScreenshot,
|
|
352
|
+
setLocation, runFlow (subflows).
|
|
353
|
+
3) JavaScript (GraalJS / modern ES):
|
|
354
|
+
- Use ${expression} for dynamic values in YAML fields.
|
|
355
|
+
- Use evalScript for short inline logic (set output.*, compute values).
|
|
356
|
+
- Use runScript: scripts/<name>.js for reusable helpers.
|
|
357
|
+
- Prefer the global `output` object to share values across steps.
|
|
358
|
+
- You may use console.log for debugging; no Node.js / filesystem APIs.
|
|
359
|
+
- faker may be used for synthetic data when helpful.
|
|
360
|
+
- Optional http helpers for API setup when needed.
|
|
361
|
+
- NEVER call maestro.visible / maestro.isVisible (undefined). Visibility checks
|
|
362
|
+
belong in YAML: assertVisible / assertNotVisible / extendedWaitUntil.
|
|
363
|
+
assertTrue is only for real JS expressions over output.* / env values.
|
|
364
|
+
4) Prefer selectors from exploration results / view hierarchy when provided;
|
|
365
|
+
else stable visible text / accessibility ids.
|
|
366
|
+
5) When exploration results include a grounded plan, follow that plan closely.
|
|
367
|
+
6) Reuse: extract repeated sequences with runFlow; use onFlowStart / onFlowComplete
|
|
368
|
+
for setup/teardown when helpful.
|
|
369
|
+
7) iOS Settings: com.apple.Preferences. Android Settings: com.android.settings.
|
|
370
|
+
8) Mobile web (https://): openLink + Safari/Chrome appId. Never emit Playwright/Appium.
|
|
371
|
+
9) Known apps — use these appIds when the goal names them:
|
|
372
|
+
Wikipedia: org.wikipedia (Android) / org.wikimedia.wikipedia (iOS).
|
|
373
|
+
Joplin: net.cozic.joplin (Android + iOS).
|
|
374
|
+
Bitwarden: com.x8bit.bitwarden (Android) / com.8bit.bitwarden (iOS).
|
|
375
|
+
10) After launchApp, optionally dismiss Skip/Next/Continue/Allow/Not now.
|
|
376
|
+
10b) Always end happy-path flows with assertVisible (goal evidence); prefer known selectors.
|
|
377
|
+
10c) For OR visibility (A or B), prefer assertVisible with regex "A|B" or two optional
|
|
378
|
+
waits plus one hard assertVisible — never assertTrue with maestro.visible.
|
|
379
|
+
11) End the flow with stopApp.
|
|
380
|
+
12) Output format — use fenced blocks:
|
|
381
|
+
```yaml flow.yaml
|
|
382
|
+
...
|
|
383
|
+
```
|
|
384
|
+
```javascript scripts/helpers.js
|
|
385
|
+
...
|
|
386
|
+
```
|
|
387
|
+
Only add .js files when the goal needs dynamic data, conditions, or HTTP helpers.
|
|
388
|
+
Keep simple smoke flows YAML-only."""
|
|
389
|
+
|
|
390
|
+
def parse_flow_bundle(text: str, *, app_id: str) -> FlowBundle:
|
|
391
|
+
"""Parse LLM (or paste) text into YAML + optional JS scripts."""
|
|
392
|
+
scripts: dict[str, str] = {}
|
|
393
|
+
yaml_body = ""
|
|
394
|
+
for name, body in extract_fenced_files(text or ""):
|
|
395
|
+
lower = name.lower()
|
|
396
|
+
if lower.endswith((".js", ".mjs")) or lower.endswith("javascript"):
|
|
397
|
+
# Normalize to scripts/<file>.js when bare filename
|
|
398
|
+
path = name
|
|
399
|
+
if "/" not in path and "\\" not in path:
|
|
400
|
+
path = f"scripts/{path}"
|
|
401
|
+
# Strip leading // file: line if present
|
|
402
|
+
lines = body.splitlines()
|
|
403
|
+
if lines and re.match(r"^//\s*file:", lines[0], re.IGNORECASE):
|
|
404
|
+
body = "\n".join(lines[1:]).strip()
|
|
405
|
+
scripts[path] = body
|
|
406
|
+
elif lower.endswith((".yaml", ".yml")) or looks_like_maestro_yaml(body):
|
|
407
|
+
if not yaml_body:
|
|
408
|
+
yaml_body = body
|
|
409
|
+
if not yaml_body:
|
|
410
|
+
yaml_body = extract_yaml_fence(text or "")
|
|
411
|
+
yaml_out = ensure_stop_app(ensure_flow_yaml(yaml_body, app_id))
|
|
412
|
+
# Rewrite runScript paths to scripts/… when we emitted helpers there
|
|
413
|
+
yaml_out = _normalize_run_script_paths(yaml_out, scripts)
|
|
414
|
+
return FlowBundle(flow_yaml=yaml_out, scripts=scripts)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _normalize_run_script_paths(flow_yaml: str, scripts: dict[str, str]) -> str:
|
|
418
|
+
if not scripts:
|
|
419
|
+
return flow_yaml
|
|
420
|
+
# Map basename → preferred relative path
|
|
421
|
+
by_base = {Path(p).name: p for p in scripts}
|
|
422
|
+
|
|
423
|
+
def repl(match: re.Match[str]) -> str:
|
|
424
|
+
raw = match.group(1).strip().strip("\"'")
|
|
425
|
+
base = Path(raw).name
|
|
426
|
+
if base in by_base:
|
|
427
|
+
return f"- runScript: {by_base[base]}"
|
|
428
|
+
if not raw.startswith("scripts/") and base.endswith(".js"):
|
|
429
|
+
return f"- runScript: scripts/{base}"
|
|
430
|
+
return match.group(0)
|
|
431
|
+
|
|
432
|
+
return re.sub(
|
|
433
|
+
r"(?m)^\s*-\s*runScript:\s*(\S+)\s*$",
|
|
434
|
+
repl,
|
|
435
|
+
flow_yaml,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
DEFAULT_HELPERS_JS = """\
|
|
440
|
+
// MobiFlow Maestro helpers (GraalJS sandbox — no Node.js APIs)
|
|
441
|
+
// Use via: - runScript: scripts/helpers.js
|
|
442
|
+
// Values on `output` are available later as ${output.key}
|
|
443
|
+
|
|
444
|
+
output.runId = 'run-' + Date.now()
|
|
445
|
+
output.ready = true
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
_EXTEND_SYSTEM_SUFFIX = """
|
|
450
|
+
INCREMENTAL EXTEND MODE:
|
|
451
|
+
You are EXTENDING an existing Maestro flow. A previous flow YAML is provided.
|
|
452
|
+
- Keep all prior working commands unless a repair is clearly required.
|
|
453
|
+
- Add commands ONLY for the NEW gap steps (after the common prefix).
|
|
454
|
+
- Return a COMPLETE flow YAML (appId + --- + full command list ending in stopApp).
|
|
455
|
+
- Do not relaunch unnecessarily if launchApp is already present.
|
|
456
|
+
- Prefer appending new steps before the final stopApp.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def generate_flow_bundle(
|
|
461
|
+
goal: str,
|
|
462
|
+
*,
|
|
463
|
+
app_id: str,
|
|
464
|
+
platform: str,
|
|
465
|
+
profile: ModelEntry,
|
|
466
|
+
hierarchy: str = "",
|
|
467
|
+
previous_yaml: str = "",
|
|
468
|
+
previous_scripts: dict[str, str] | None = None,
|
|
469
|
+
failure_log: str = "",
|
|
470
|
+
exploration: str = "",
|
|
471
|
+
allow_js: bool = True,
|
|
472
|
+
extend: bool = False,
|
|
473
|
+
progress: ProgressFn = None,
|
|
474
|
+
) -> FlowBundle:
|
|
475
|
+
"""NL (or pasted YAML) → Maestro FlowBundle (YAML + optional JS)."""
|
|
476
|
+
goal = (goal or "").strip()
|
|
477
|
+
if looks_like_maestro_yaml(goal):
|
|
478
|
+
return parse_flow_bundle(goal, app_id=app_id)
|
|
479
|
+
|
|
480
|
+
resolved = resolve_app_id(app_id, platform, goal)
|
|
481
|
+
# Deterministic shortcuts (YAML-only — no JS needed) when no explore/repair context
|
|
482
|
+
gl = goal.lower()
|
|
483
|
+
if (
|
|
484
|
+
not extend
|
|
485
|
+
and not exploration.strip()
|
|
486
|
+
and not previous_yaml
|
|
487
|
+
and "settings" in gl
|
|
488
|
+
and ("open" in gl or "launch" in gl)
|
|
489
|
+
):
|
|
490
|
+
return FlowBundle(flow_yaml=_settings_flow(platform))
|
|
491
|
+
if (
|
|
492
|
+
not extend
|
|
493
|
+
and not exploration.strip()
|
|
494
|
+
and not previous_yaml
|
|
495
|
+
and "wikipedia" in gl
|
|
496
|
+
and ("open" in gl or "launch" in gl)
|
|
497
|
+
and "search" not in gl
|
|
498
|
+
):
|
|
499
|
+
return FlowBundle(flow_yaml=_wikipedia_open_flow(platform, resolved))
|
|
500
|
+
|
|
501
|
+
if progress:
|
|
502
|
+
mode = "extend" if extend else "author"
|
|
503
|
+
progress(
|
|
504
|
+
f"{'Extending' if extend else 'Authoring'} Maestro YAML"
|
|
505
|
+
+ (" + JS" if allow_js else "")
|
|
506
|
+
+ (" from exploration" if exploration.strip() else "")
|
|
507
|
+
+ " with LLM…"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
system = _MAESTRO_SYSTEM_JS if allow_js else _MAESTRO_SYSTEM_YAML
|
|
511
|
+
if extend and previous_yaml.strip():
|
|
512
|
+
system = system + "\n" + _EXTEND_SYSTEM_SUFFIX
|
|
513
|
+
llm_config = profile_to_llm_config(profile)
|
|
514
|
+
user_parts = [
|
|
515
|
+
f"Platform: {platform or 'android'}",
|
|
516
|
+
f"App ID: {resolved}",
|
|
517
|
+
f"JavaScript enabled: {str(allow_js).lower()}",
|
|
518
|
+
f"Goal:\n{goal}",
|
|
519
|
+
]
|
|
520
|
+
if exploration.strip():
|
|
521
|
+
user_parts.append(exploration.strip()[:12000])
|
|
522
|
+
if previous_yaml.strip():
|
|
523
|
+
label = "Previous flow YAML to extend" if extend else "Previous flow YAML to repair"
|
|
524
|
+
user_parts.append(
|
|
525
|
+
f"{label}:\n```yaml\n{previous_yaml.strip()}\n```"
|
|
526
|
+
)
|
|
527
|
+
if previous_scripts:
|
|
528
|
+
for name, body in previous_scripts.items():
|
|
529
|
+
user_parts.append(
|
|
530
|
+
f"Previous script `{name}`:\n```javascript {name}\n{body}\n```"
|
|
531
|
+
)
|
|
532
|
+
if failure_log.strip():
|
|
533
|
+
user_parts.append(f"Failure log:\n{failure_log.strip()[:6000]}")
|
|
534
|
+
if hierarchy.strip():
|
|
535
|
+
user_parts.append(
|
|
536
|
+
f"Current view hierarchy (truncated):\n{hierarchy.strip()[:8000]}"
|
|
537
|
+
)
|
|
538
|
+
if extend and previous_yaml.strip():
|
|
539
|
+
user_parts.append(
|
|
540
|
+
"Return a COMPLETE extended Maestro flow in ```yaml flow.yaml``` "
|
|
541
|
+
"(prior steps + new gap steps). End with stopApp."
|
|
542
|
+
)
|
|
543
|
+
elif allow_js:
|
|
544
|
+
user_parts.append(
|
|
545
|
+
"Return ```yaml flow.yaml``` and optional ```javascript scripts/<name>.js```. "
|
|
546
|
+
"End YAML with stopApp."
|
|
547
|
+
)
|
|
548
|
+
else:
|
|
549
|
+
user_parts.append(
|
|
550
|
+
"Return a complete Maestro flow in a ```yaml fence. End with stopApp."
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
usage_bucket: list[ChatUsage] = []
|
|
554
|
+
text = await asyncio.to_thread(
|
|
555
|
+
invoke_chat_text,
|
|
556
|
+
system,
|
|
557
|
+
"\n\n".join(user_parts),
|
|
558
|
+
llm_config,
|
|
559
|
+
max_tokens=4096,
|
|
560
|
+
temperature=0.2,
|
|
561
|
+
log_prefix="MobiFlow",
|
|
562
|
+
usage_out=usage_bucket,
|
|
563
|
+
)
|
|
564
|
+
bundle = parse_flow_bundle(text or "", app_id=resolved)
|
|
565
|
+
bundle.usage = merge_usage_list(usage_bucket)
|
|
566
|
+
if extend and previous_yaml.strip():
|
|
567
|
+
from mobiflow.incremental import merge_flow_yaml
|
|
568
|
+
|
|
569
|
+
bundle.flow_yaml = merge_flow_yaml(
|
|
570
|
+
previous_yaml, bundle.flow_yaml, app_id=resolved
|
|
571
|
+
)
|
|
572
|
+
if not allow_js:
|
|
573
|
+
# Strip JS if project disabled it
|
|
574
|
+
bundle.scripts = {}
|
|
575
|
+
# Remove runScript/evalScript lines if model ignored instructions
|
|
576
|
+
cleaned = re.sub(
|
|
577
|
+
r"(?m)^\s*-\s*(evalScript|runScript):.*(?:\n(?:\s{2,}.+)*)?",
|
|
578
|
+
"",
|
|
579
|
+
bundle.flow_yaml,
|
|
580
|
+
)
|
|
581
|
+
bundle.flow_yaml = ensure_stop_app(cleaned)
|
|
582
|
+
return bundle
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
async def generate_flow_yaml(
|
|
586
|
+
goal: str,
|
|
587
|
+
*,
|
|
588
|
+
app_id: str,
|
|
589
|
+
platform: str,
|
|
590
|
+
profile: ModelEntry,
|
|
591
|
+
hierarchy: str = "",
|
|
592
|
+
previous_yaml: str = "",
|
|
593
|
+
failure_log: str = "",
|
|
594
|
+
exploration: str = "",
|
|
595
|
+
allow_js: bool = True,
|
|
596
|
+
progress: ProgressFn = None,
|
|
597
|
+
) -> str:
|
|
598
|
+
"""NL → Maestro flow YAML (compat wrapper)."""
|
|
599
|
+
bundle = await generate_flow_bundle(
|
|
600
|
+
goal,
|
|
601
|
+
app_id=app_id,
|
|
602
|
+
platform=platform,
|
|
603
|
+
profile=profile,
|
|
604
|
+
hierarchy=hierarchy,
|
|
605
|
+
previous_yaml=previous_yaml,
|
|
606
|
+
failure_log=failure_log,
|
|
607
|
+
exploration=exploration,
|
|
608
|
+
allow_js=allow_js,
|
|
609
|
+
progress=progress,
|
|
610
|
+
)
|
|
611
|
+
return bundle.flow_yaml
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _settings_flow(platform: str) -> str:
|
|
615
|
+
aid = "com.apple.Preferences" if platform == "ios" else "com.android.settings"
|
|
616
|
+
visible = "Settings|General|Wi-Fi|Network" if platform == "ios" else "Settings|Network|Apps"
|
|
617
|
+
return (
|
|
618
|
+
f"appId: {aid}\n"
|
|
619
|
+
f"name: Open system Settings\n"
|
|
620
|
+
f"---\n"
|
|
621
|
+
f"- launchApp\n"
|
|
622
|
+
f'- assertVisible: "{visible}"\n'
|
|
623
|
+
f"- stopApp\n"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _wikipedia_open_flow(platform: str, app_id: str) -> str:
|
|
628
|
+
aid = app_id or (
|
|
629
|
+
"org.wikimedia.wikipedia" if platform == "ios" else "org.wikipedia"
|
|
630
|
+
)
|
|
631
|
+
return (
|
|
632
|
+
f"appId: {aid}\n"
|
|
633
|
+
f"name: Open Wikipedia\n"
|
|
634
|
+
f"---\n"
|
|
635
|
+
f"- launchApp\n"
|
|
636
|
+
f"- tapOn:\n"
|
|
637
|
+
f' text: "Skip|Next|Continue|Get started|Allow|Not now"\n'
|
|
638
|
+
f" optional: true\n"
|
|
639
|
+
f'- assertVisible: "Search|Explore|Wikipedia"\n'
|
|
640
|
+
f"- stopApp\n"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async def fetch_hierarchy(device_id: str | None = None) -> str:
|
|
645
|
+
binary = resolve_maestro_binary()
|
|
646
|
+
if not binary:
|
|
647
|
+
return ""
|
|
648
|
+
args = [binary, "hierarchy"]
|
|
649
|
+
if device_id:
|
|
650
|
+
args.extend(["--device", device_id])
|
|
651
|
+
result = await _run_cmd(args, timeout=60.0)
|
|
652
|
+
return (result.get("stdout") or "")[:12000]
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _maestro_test_args(
|
|
656
|
+
binary: str,
|
|
657
|
+
flow_path: Path,
|
|
658
|
+
*,
|
|
659
|
+
device_id: str | None = None,
|
|
660
|
+
artifact_dir: Path | None = None,
|
|
661
|
+
flow_env: dict[str, str] | None = None,
|
|
662
|
+
include_tags: list[str] | None = None,
|
|
663
|
+
exclude_tags: list[str] | None = None,
|
|
664
|
+
maestro_config: str | Path | None = None,
|
|
665
|
+
platform: str | None = None,
|
|
666
|
+
) -> list[str]:
|
|
667
|
+
from mobiflow.secrets import maestro_env_args
|
|
668
|
+
|
|
669
|
+
args = [binary, "test", str(flow_path)]
|
|
670
|
+
if device_id:
|
|
671
|
+
args.extend(["--device", device_id])
|
|
672
|
+
if platform and platform.lower() in {"ios", "android", "web"}:
|
|
673
|
+
args.extend(["--platform", platform.lower()])
|
|
674
|
+
if flow_env:
|
|
675
|
+
args.extend(maestro_env_args(flow_env))
|
|
676
|
+
if include_tags:
|
|
677
|
+
args.append("--include-tags=" + ",".join(include_tags))
|
|
678
|
+
if exclude_tags:
|
|
679
|
+
args.append("--exclude-tags=" + ",".join(exclude_tags))
|
|
680
|
+
cfg = str(maestro_config or "").strip()
|
|
681
|
+
if cfg and Path(cfg).is_file():
|
|
682
|
+
args.extend(["--config", cfg])
|
|
683
|
+
if artifact_dir is not None:
|
|
684
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
685
|
+
debug_dir = artifact_dir / "maestro-debug"
|
|
686
|
+
test_out = artifact_dir / "maestro-output"
|
|
687
|
+
junit_path = artifact_dir / "maestro-junit.xml"
|
|
688
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
689
|
+
test_out.mkdir(parents=True, exist_ok=True)
|
|
690
|
+
args.extend(
|
|
691
|
+
[
|
|
692
|
+
"--debug-output",
|
|
693
|
+
str(debug_dir),
|
|
694
|
+
"--flatten-debug-output",
|
|
695
|
+
"--test-output-dir",
|
|
696
|
+
str(test_out),
|
|
697
|
+
"--format",
|
|
698
|
+
"JUNIT",
|
|
699
|
+
"--output",
|
|
700
|
+
str(junit_path),
|
|
701
|
+
]
|
|
702
|
+
)
|
|
703
|
+
return args
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def find_local_videos(root: Path, *, limit: int = 8) -> list[Path]:
|
|
707
|
+
"""Find MP4/WebM/MOV artifacts under a Maestro run directory."""
|
|
708
|
+
if not root.exists():
|
|
709
|
+
return []
|
|
710
|
+
found: list[Path] = []
|
|
711
|
+
for path in sorted(root.rglob("*")):
|
|
712
|
+
if path.is_file() and path.suffix.lower() in {".mp4", ".webm", ".mov", ".m4v"}:
|
|
713
|
+
found.append(path)
|
|
714
|
+
if len(found) >= limit:
|
|
715
|
+
break
|
|
716
|
+
return found
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def _maybe_record_video(
|
|
720
|
+
binary: str,
|
|
721
|
+
flow_path: Path,
|
|
722
|
+
*,
|
|
723
|
+
cwd: Path,
|
|
724
|
+
device_id: str | None,
|
|
725
|
+
artifact_dir: Path,
|
|
726
|
+
flow_env: dict[str, str] | None,
|
|
727
|
+
timeout_s: float,
|
|
728
|
+
progress: ProgressFn = None,
|
|
729
|
+
) -> str:
|
|
730
|
+
"""Run `maestro record --local` and return absolute video path if produced."""
|
|
731
|
+
from mobiflow.secrets import maestro_env_args
|
|
732
|
+
|
|
733
|
+
videos = artifact_dir / "videos"
|
|
734
|
+
videos.mkdir(parents=True, exist_ok=True)
|
|
735
|
+
out_mp4 = videos / "execution.mp4"
|
|
736
|
+
args = [
|
|
737
|
+
binary,
|
|
738
|
+
"record",
|
|
739
|
+
str(flow_path),
|
|
740
|
+
"--local",
|
|
741
|
+
str(out_mp4),
|
|
742
|
+
]
|
|
743
|
+
if device_id:
|
|
744
|
+
args.extend(["--device", device_id])
|
|
745
|
+
if flow_env:
|
|
746
|
+
args.extend(maestro_env_args(flow_env))
|
|
747
|
+
debug_dir = artifact_dir / "maestro-record-debug"
|
|
748
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
749
|
+
args.extend(["--debug-output", str(debug_dir)])
|
|
750
|
+
if progress:
|
|
751
|
+
progress("Recording execution video (`maestro record --local`)…")
|
|
752
|
+
result = await _run_cmd(args, timeout=timeout_s, cwd=str(cwd))
|
|
753
|
+
if out_mp4.is_file() and out_mp4.stat().st_size > 0:
|
|
754
|
+
return str(out_mp4.resolve())
|
|
755
|
+
# Some CLI versions write beside cwd / debug dir
|
|
756
|
+
for cand in find_local_videos(artifact_dir):
|
|
757
|
+
return str(cand.resolve())
|
|
758
|
+
if not result.get("ok") and progress:
|
|
759
|
+
err = (result.get("stderr") or result.get("error") or "record_failed")[:200]
|
|
760
|
+
progress(f"Video record skipped: {err}")
|
|
761
|
+
return ""
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
async def run_flow_yaml(
|
|
765
|
+
flow_yaml: str,
|
|
766
|
+
*,
|
|
767
|
+
device_id: str | None = None,
|
|
768
|
+
timeout_s: int = 180,
|
|
769
|
+
scripts: dict[str, str] | None = None,
|
|
770
|
+
work_dir: Path | None = None,
|
|
771
|
+
progress: ProgressFn = None,
|
|
772
|
+
device_config: Any = None,
|
|
773
|
+
platform: str | None = None,
|
|
774
|
+
artifact_dir: Path | None = None,
|
|
775
|
+
flow_env: dict[str, str] | None = None,
|
|
776
|
+
record_video: bool = False,
|
|
777
|
+
include_tags: list[str] | None = None,
|
|
778
|
+
exclude_tags: list[str] | None = None,
|
|
779
|
+
maestro_config: str | Path | None = None,
|
|
780
|
+
) -> dict[str, Any]:
|
|
781
|
+
"""Run Maestro YAML locally or on a cloud device lab.
|
|
782
|
+
|
|
783
|
+
When ``device_config.provider`` is ``browserstack``, ``testmu``, or ``maestro``,
|
|
784
|
+
uploads the flow (and app) and executes via that lab instead of local ``maestro test``.
|
|
785
|
+
|
|
786
|
+
For local runs, ``artifact_dir`` enables Maestro ``--debug-output``,
|
|
787
|
+
``--test-output-dir``, and JUnit ``--format/--output``. When ``record_video``
|
|
788
|
+
is true, also runs ``maestro record --local`` for an MP4 artifact.
|
|
789
|
+
"""
|
|
790
|
+
from mobiflow.cloud.base import is_cloud_provider
|
|
791
|
+
|
|
792
|
+
if device_config is not None and is_cloud_provider(
|
|
793
|
+
getattr(device_config, "provider", "local")
|
|
794
|
+
):
|
|
795
|
+
from mobiflow.cloud.runner import request_from_device_config, run_on_cloud
|
|
796
|
+
|
|
797
|
+
if progress:
|
|
798
|
+
progress(
|
|
799
|
+
f"Running on cloud provider={device_config.provider} "
|
|
800
|
+
f"device={device_id or device_config.device_id or '(default)'}…"
|
|
801
|
+
)
|
|
802
|
+
req = request_from_device_config(
|
|
803
|
+
device_config,
|
|
804
|
+
flow_yaml=flow_yaml,
|
|
805
|
+
scripts=scripts,
|
|
806
|
+
platform=platform or getattr(device_config, "platform", "android"),
|
|
807
|
+
device_id=device_id,
|
|
808
|
+
timeout_s=max(
|
|
809
|
+
timeout_s, int(getattr(device_config, "cloud_timeout_s", 1800) or 1800)
|
|
810
|
+
),
|
|
811
|
+
)
|
|
812
|
+
cloud_result = await run_on_cloud(
|
|
813
|
+
req, progress=progress, artifact_dir=artifact_dir
|
|
814
|
+
)
|
|
815
|
+
out = cloud_result.as_run_dict()
|
|
816
|
+
out["flow_yaml"] = flow_yaml
|
|
817
|
+
out["scripts"] = scripts or {}
|
|
818
|
+
if artifact_dir is not None:
|
|
819
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
820
|
+
(artifact_dir / "cloud-result.json").write_text(
|
|
821
|
+
json.dumps(
|
|
822
|
+
{
|
|
823
|
+
"provider": out.get("provider"),
|
|
824
|
+
"build_id": out.get("build_id"),
|
|
825
|
+
"status": out.get("status"),
|
|
826
|
+
"dashboard_url": out.get("dashboard_url"),
|
|
827
|
+
"video_url": out.get("video_url"),
|
|
828
|
+
"media_files": out.get("media_files"),
|
|
829
|
+
"media_dir": out.get("media_dir"),
|
|
830
|
+
"ok": out.get("ok"),
|
|
831
|
+
},
|
|
832
|
+
indent=2,
|
|
833
|
+
)
|
|
834
|
+
+ "\n",
|
|
835
|
+
encoding="utf-8",
|
|
836
|
+
)
|
|
837
|
+
out["artifact_dir"] = str(artifact_dir)
|
|
838
|
+
# Mirror downloaded cloud screenshots into screenshots/ for reports
|
|
839
|
+
media_dir = Path(out["media_dir"]) if out.get("media_dir") else (
|
|
840
|
+
artifact_dir / "cloud"
|
|
841
|
+
)
|
|
842
|
+
if media_dir.is_dir():
|
|
843
|
+
shots = artifact_dir / "screenshots"
|
|
844
|
+
for src in sorted(media_dir.iterdir()):
|
|
845
|
+
if src.is_file() and src.suffix.lower() in {
|
|
846
|
+
".png",
|
|
847
|
+
".jpg",
|
|
848
|
+
".jpeg",
|
|
849
|
+
".webp",
|
|
850
|
+
".gif",
|
|
851
|
+
}:
|
|
852
|
+
shots.mkdir(parents=True, exist_ok=True)
|
|
853
|
+
dest = shots / src.name
|
|
854
|
+
if not dest.exists():
|
|
855
|
+
dest.write_bytes(src.read_bytes())
|
|
856
|
+
return out
|
|
857
|
+
|
|
858
|
+
binary = resolve_maestro_binary()
|
|
859
|
+
if not binary:
|
|
860
|
+
return {
|
|
861
|
+
"ok": False,
|
|
862
|
+
"error": "maestro_not_installed",
|
|
863
|
+
"stdout": "",
|
|
864
|
+
"stderr": "Maestro CLI not found",
|
|
865
|
+
"returncode": -1,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
def _write_bundle(root: Path) -> Path:
|
|
869
|
+
flow_path = root / "flow.yaml"
|
|
870
|
+
flow_path.write_text(flow_yaml, encoding="utf-8")
|
|
871
|
+
for rel, body in (scripts or {}).items():
|
|
872
|
+
sp = root / rel
|
|
873
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
874
|
+
sp.write_text(body, encoding="utf-8")
|
|
875
|
+
return flow_path
|
|
876
|
+
|
|
877
|
+
async def _run_local(root: Path, art: Path | None) -> dict[str, Any]:
|
|
878
|
+
flow_path = _write_bundle(root)
|
|
879
|
+
args = _maestro_test_args(
|
|
880
|
+
binary,
|
|
881
|
+
flow_path,
|
|
882
|
+
device_id=device_id,
|
|
883
|
+
artifact_dir=art,
|
|
884
|
+
flow_env=flow_env,
|
|
885
|
+
include_tags=include_tags,
|
|
886
|
+
exclude_tags=exclude_tags,
|
|
887
|
+
maestro_config=maestro_config,
|
|
888
|
+
platform=platform,
|
|
889
|
+
)
|
|
890
|
+
if progress:
|
|
891
|
+
progress(f"Running maestro test{' on ' + device_id if device_id else ''}…")
|
|
892
|
+
result = await _run_cmd(args, timeout=float(timeout_s), cwd=str(root))
|
|
893
|
+
result["flow_yaml"] = flow_yaml
|
|
894
|
+
result["scripts"] = scripts or {}
|
|
895
|
+
if art is not None:
|
|
896
|
+
result["artifact_dir"] = str(art)
|
|
897
|
+
junit = art / "maestro-junit.xml"
|
|
898
|
+
if junit.is_file():
|
|
899
|
+
result["maestro_junit"] = str(junit)
|
|
900
|
+
try:
|
|
901
|
+
(art / "flow.yaml").write_text(flow_yaml, encoding="utf-8")
|
|
902
|
+
except OSError:
|
|
903
|
+
pass
|
|
904
|
+
result["maestro_debug_dir"] = str(art / "maestro-debug")
|
|
905
|
+
result["maestro_output_dir"] = str(art / "maestro-output")
|
|
906
|
+
video_path = ""
|
|
907
|
+
if record_video:
|
|
908
|
+
video_path = await _maybe_record_video(
|
|
909
|
+
binary,
|
|
910
|
+
flow_path,
|
|
911
|
+
cwd=root,
|
|
912
|
+
device_id=device_id,
|
|
913
|
+
artifact_dir=art,
|
|
914
|
+
flow_env=flow_env,
|
|
915
|
+
timeout_s=float(timeout_s),
|
|
916
|
+
progress=progress,
|
|
917
|
+
)
|
|
918
|
+
if not video_path:
|
|
919
|
+
found = find_local_videos(art)
|
|
920
|
+
if found:
|
|
921
|
+
video_path = str(found[0].resolve())
|
|
922
|
+
if video_path:
|
|
923
|
+
result["video_url"] = video_path
|
|
924
|
+
# Also copy into videos/ for stable report paths
|
|
925
|
+
try:
|
|
926
|
+
vdir = art / "videos"
|
|
927
|
+
vdir.mkdir(parents=True, exist_ok=True)
|
|
928
|
+
src = Path(video_path)
|
|
929
|
+
dest = vdir / src.name
|
|
930
|
+
if src.resolve() != dest.resolve() and src.is_file():
|
|
931
|
+
dest.write_bytes(src.read_bytes())
|
|
932
|
+
result["video_url"] = str(dest.resolve())
|
|
933
|
+
except OSError:
|
|
934
|
+
pass
|
|
935
|
+
result["work_dir"] = str(root)
|
|
936
|
+
return result
|
|
937
|
+
|
|
938
|
+
if work_dir is not None:
|
|
939
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
940
|
+
return await _run_local(work_dir, artifact_dir)
|
|
941
|
+
|
|
942
|
+
if artifact_dir is not None:
|
|
943
|
+
root = Path(artifact_dir) / "bundle"
|
|
944
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
return await _run_local(root, artifact_dir)
|
|
946
|
+
|
|
947
|
+
with tempfile.TemporaryDirectory(prefix="mobiflow-") as tmp:
|
|
948
|
+
return await _run_local(Path(tmp), None)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
async def run_mobile_task(
|
|
952
|
+
goal: str,
|
|
953
|
+
*,
|
|
954
|
+
codegen_profile: ModelEntry,
|
|
955
|
+
discovery_profile: ModelEntry | None = None,
|
|
956
|
+
app_id: str = "",
|
|
957
|
+
platform: str = "android",
|
|
958
|
+
device_id: str | None = None,
|
|
959
|
+
heal: int = 2,
|
|
960
|
+
adaptive: bool = True,
|
|
961
|
+
explore: bool = True,
|
|
962
|
+
explore_steps: int = 5,
|
|
963
|
+
timeout_s: int = 180,
|
|
964
|
+
live: bool = True,
|
|
965
|
+
allow_js: bool = True,
|
|
966
|
+
auto_start_device: bool = True,
|
|
967
|
+
progress: ProgressFn = None,
|
|
968
|
+
device_config: Any = None,
|
|
969
|
+
artifact_dir: Path | None = None,
|
|
970
|
+
clear_state: bool = False,
|
|
971
|
+
preflight: list[str] | None = None,
|
|
972
|
+
app_path: str = "",
|
|
973
|
+
retries: int = 0,
|
|
974
|
+
reuse_flow_yaml: str | None = None,
|
|
975
|
+
reuse_scripts: dict[str, str] | None = None,
|
|
976
|
+
flow_env: dict[str, str] | None = None,
|
|
977
|
+
expect: list[str] | None = None,
|
|
978
|
+
prior_flow_yaml: str | None = None,
|
|
979
|
+
prior_scripts: dict[str, str] | None = None,
|
|
980
|
+
extend: bool = False,
|
|
981
|
+
replay_prefix: bool = False,
|
|
982
|
+
explore_goal: str | None = None,
|
|
983
|
+
codegen_goal: str | None = None,
|
|
984
|
+
record_video: bool = False,
|
|
985
|
+
include_tags: list[str] | None = None,
|
|
986
|
+
exclude_tags: list[str] | None = None,
|
|
987
|
+
maestro_config: str | Path | None = None,
|
|
988
|
+
) -> dict[str, Any]:
|
|
989
|
+
"""Full agent loop: preflight → explore → author YAML(+JS) → run → heal.
|
|
990
|
+
|
|
991
|
+
``retries`` re-runs the same YAML before each heal. ``reuse_flow_yaml``
|
|
992
|
+
skips explore/codegen and executes the provided flow (optionally with heal).
|
|
993
|
+
``flow_env`` is passed to Maestro as ``--env KEY=VALUE``.
|
|
994
|
+
|
|
995
|
+
Incremental / extend modes (mutually exclusive with reuse at the pipeline layer):
|
|
996
|
+
- ``replay_prefix``: run prior YAML without ``stopApp``, then explore ``explore_goal``
|
|
997
|
+
- ``extend``: codegen extends ``prior_flow_yaml`` for new steps
|
|
998
|
+
"""
|
|
999
|
+
from mobiflow.cloud.base import is_cloud_provider
|
|
1000
|
+
from mobiflow.devices import ensure_device
|
|
1001
|
+
from mobiflow.explore import ExplorationResult, explore_app, plan_only_explore
|
|
1002
|
+
from mobiflow.maestro.lifecycle import normalize_preflight, run_preflight
|
|
1003
|
+
|
|
1004
|
+
logs: list[str] = []
|
|
1005
|
+
run_root = Path(artifact_dir) if artifact_dir else None
|
|
1006
|
+
if run_root is not None:
|
|
1007
|
+
run_root.mkdir(parents=True, exist_ok=True)
|
|
1008
|
+
discovery = discovery_profile or codegen_profile
|
|
1009
|
+
preflight_meta: dict[str, Any] = {}
|
|
1010
|
+
|
|
1011
|
+
def _p(msg: str) -> None:
|
|
1012
|
+
logs.append(msg)
|
|
1013
|
+
if progress:
|
|
1014
|
+
progress(msg)
|
|
1015
|
+
|
|
1016
|
+
cloud = bool(
|
|
1017
|
+
device_config is not None
|
|
1018
|
+
and is_cloud_provider(getattr(device_config, "provider", "local"))
|
|
1019
|
+
)
|
|
1020
|
+
provider = (
|
|
1021
|
+
getattr(device_config, "provider", "local") if device_config is not None else "local"
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
status = await get_status()
|
|
1025
|
+
if cloud:
|
|
1026
|
+
from mobiflow.cloud import cloud_readiness
|
|
1027
|
+
|
|
1028
|
+
ready = cloud_readiness(device_config)
|
|
1029
|
+
_p(ready.get("message") or f"Cloud provider={provider}")
|
|
1030
|
+
# Cloud runs do not need local Maestro CLI / adb
|
|
1031
|
+
status = {
|
|
1032
|
+
**status,
|
|
1033
|
+
"ready": bool(ready.get("ready")),
|
|
1034
|
+
"cloud": ready,
|
|
1035
|
+
"message": ready.get("message") or status.get("message"),
|
|
1036
|
+
}
|
|
1037
|
+
else:
|
|
1038
|
+
_p(status.get("message") or "Checking Maestro…")
|
|
1039
|
+
|
|
1040
|
+
selected = (device_id or "").strip()
|
|
1041
|
+
if cloud:
|
|
1042
|
+
selected = selected or (getattr(device_config, "device_id", None) or "")
|
|
1043
|
+
selected = (selected or "").strip()
|
|
1044
|
+
if not selected:
|
|
1045
|
+
from mobiflow.cloud.base import devices_from_config, normalize_provider
|
|
1046
|
+
|
|
1047
|
+
selected = devices_from_config(
|
|
1048
|
+
None,
|
|
1049
|
+
platform=platform,
|
|
1050
|
+
provider=normalize_provider(provider),
|
|
1051
|
+
)[0]
|
|
1052
|
+
_p(f"Using default cloud device: {selected}")
|
|
1053
|
+
elif live:
|
|
1054
|
+
boot_timeout = float(timeout_s or 120)
|
|
1055
|
+
if device_config is not None and getattr(device_config, "boot_timeout_s", None):
|
|
1056
|
+
boot_timeout = float(device_config.boot_timeout_s)
|
|
1057
|
+
ensured = await ensure_device(
|
|
1058
|
+
platform_pref=platform,
|
|
1059
|
+
device_id=selected or None,
|
|
1060
|
+
auto_start=auto_start_device,
|
|
1061
|
+
timeout_s=max(90.0, boot_timeout),
|
|
1062
|
+
progress=_p,
|
|
1063
|
+
use_maestro_cli=bool(
|
|
1064
|
+
getattr(device_config, "use_maestro_cli", True)
|
|
1065
|
+
if device_config is not None
|
|
1066
|
+
else True
|
|
1067
|
+
),
|
|
1068
|
+
device_model=(
|
|
1069
|
+
str(getattr(device_config, "device_model", "") or "")
|
|
1070
|
+
if device_config is not None
|
|
1071
|
+
else ""
|
|
1072
|
+
),
|
|
1073
|
+
device_os=(
|
|
1074
|
+
str(getattr(device_config, "device_os", "") or "")
|
|
1075
|
+
if device_config is not None
|
|
1076
|
+
else ""
|
|
1077
|
+
),
|
|
1078
|
+
device_locale=(
|
|
1079
|
+
str(getattr(device_config, "device_locale", "") or "")
|
|
1080
|
+
if device_config is not None
|
|
1081
|
+
else ""
|
|
1082
|
+
),
|
|
1083
|
+
)
|
|
1084
|
+
if ensured.get("ok") and ensured.get("device"):
|
|
1085
|
+
selected = ensured["device"].get("id") or selected
|
|
1086
|
+
platform = ensured["device"].get("platform") or platform
|
|
1087
|
+
if ensured.get("started"):
|
|
1088
|
+
_p(f"Auto-started device {selected}")
|
|
1089
|
+
elif auto_start_device:
|
|
1090
|
+
_p(ensured.get("message") or "Could not ensure a device")
|
|
1091
|
+
else:
|
|
1092
|
+
# gen-only: still prefer an online id if present
|
|
1093
|
+
devices = list(status.get("devices") or [])
|
|
1094
|
+
if not selected and devices:
|
|
1095
|
+
plat = (platform or "").lower()
|
|
1096
|
+
match = next((d for d in devices if d.get("platform") == plat), None)
|
|
1097
|
+
selected = (match or devices[0]).get("id") or ""
|
|
1098
|
+
|
|
1099
|
+
if selected and not cloud:
|
|
1100
|
+
platform = infer_platform(selected, platform)
|
|
1101
|
+
|
|
1102
|
+
# Local lifecycle: install APK + Maestro clearState before explore/run
|
|
1103
|
+
preflight_steps = normalize_preflight(preflight)
|
|
1104
|
+
pkg_path = (
|
|
1105
|
+
app_path
|
|
1106
|
+
or (getattr(device_config, "app_path", "") if device_config is not None else "")
|
|
1107
|
+
or ""
|
|
1108
|
+
).strip()
|
|
1109
|
+
if live and selected and not cloud and (preflight_steps or clear_state):
|
|
1110
|
+
preflight_meta = await run_preflight(
|
|
1111
|
+
app_id=resolve_app_id(app_id, platform, goal),
|
|
1112
|
+
platform=platform,
|
|
1113
|
+
device_id=selected,
|
|
1114
|
+
steps=preflight_steps,
|
|
1115
|
+
app_path=pkg_path,
|
|
1116
|
+
clear_state=clear_state,
|
|
1117
|
+
progress=_p,
|
|
1118
|
+
timeout_s=min(90, int(timeout_s) or 90),
|
|
1119
|
+
)
|
|
1120
|
+
if not preflight_meta.get("ok"):
|
|
1121
|
+
return {
|
|
1122
|
+
"success": False,
|
|
1123
|
+
"summary": preflight_meta.get("message") or "preflight failed",
|
|
1124
|
+
"error": preflight_meta.get("error") or "preflight_failed",
|
|
1125
|
+
"logs": logs,
|
|
1126
|
+
"device_id": selected,
|
|
1127
|
+
"platform": platform,
|
|
1128
|
+
"provider": provider,
|
|
1129
|
+
"preflight": preflight_meta,
|
|
1130
|
+
"run": {},
|
|
1131
|
+
}
|
|
1132
|
+
if preflight_meta.get("steps"):
|
|
1133
|
+
_p("Preflight done: " + ", ".join(preflight_meta["steps"]))
|
|
1134
|
+
elif live and cloud and (clear_state or "clear" in preflight_steps):
|
|
1135
|
+
_p("Preflight clearState is local-only — cloud install uses device.app_path")
|
|
1136
|
+
|
|
1137
|
+
scripts: dict[str, str] = {}
|
|
1138
|
+
exploration = ExplorationResult(
|
|
1139
|
+
goal=goal,
|
|
1140
|
+
app_id=app_id,
|
|
1141
|
+
platform=platform,
|
|
1142
|
+
mode="skipped",
|
|
1143
|
+
)
|
|
1144
|
+
exploration_prompt = ""
|
|
1145
|
+
codegen_usage = ChatUsage()
|
|
1146
|
+
max_retries = max(0, min(int(retries or 0), 10))
|
|
1147
|
+
from mobiflow.selectors import (
|
|
1148
|
+
ensure_expect_asserts,
|
|
1149
|
+
load_selector_memory,
|
|
1150
|
+
memory_to_prompt_block,
|
|
1151
|
+
merge_selectors,
|
|
1152
|
+
save_selector_memory,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
resolved_app = resolve_app_id(app_id, platform, goal)
|
|
1156
|
+
# run_root = .mobiflow/runs/<case-ts> → artifacts root = .mobiflow
|
|
1157
|
+
art_root = run_root.parent.parent if run_root is not None else None
|
|
1158
|
+
selector_memory: dict[str, Any] = (
|
|
1159
|
+
load_selector_memory(art_root, resolved_app) if art_root is not None else {}
|
|
1160
|
+
)
|
|
1161
|
+
mem_block = memory_to_prompt_block(selector_memory)
|
|
1162
|
+
|
|
1163
|
+
def _persist_selectors(success: bool) -> None:
|
|
1164
|
+
if art_root is None:
|
|
1165
|
+
return
|
|
1166
|
+
sels = list(exploration.selectors or [])
|
|
1167
|
+
if not sels and not selector_memory:
|
|
1168
|
+
return
|
|
1169
|
+
updated = merge_selectors(selector_memory, sels, success=success)
|
|
1170
|
+
try:
|
|
1171
|
+
save_selector_memory(art_root, resolved_app, updated)
|
|
1172
|
+
except OSError:
|
|
1173
|
+
pass
|
|
1174
|
+
|
|
1175
|
+
# Frozen / reused flow — skip explore + codegen
|
|
1176
|
+
if reuse_flow_yaml and looks_like_maestro_yaml(reuse_flow_yaml):
|
|
1177
|
+
bundle = parse_flow_bundle(reuse_flow_yaml, app_id=app_id)
|
|
1178
|
+
flow = bundle.flow_yaml
|
|
1179
|
+
scripts = dict(reuse_scripts or {}) or bundle.scripts
|
|
1180
|
+
_p("Reusing frozen Maestro YAML (skipped explore/codegen).")
|
|
1181
|
+
# Paste-to-run
|
|
1182
|
+
elif looks_like_maestro_yaml(goal):
|
|
1183
|
+
bundle = parse_flow_bundle(goal, app_id=app_id)
|
|
1184
|
+
flow = bundle.flow_yaml
|
|
1185
|
+
scripts = bundle.scripts
|
|
1186
|
+
_p("Detected Maestro YAML — running as-is.")
|
|
1187
|
+
else:
|
|
1188
|
+
from mobiflow.incremental import strip_trailing_stop_app
|
|
1189
|
+
|
|
1190
|
+
explore_text = (explore_goal or goal).strip() or goal
|
|
1191
|
+
codegen_text = (codegen_goal or goal).strip() or goal
|
|
1192
|
+
prior_yaml = (prior_flow_yaml or "").strip()
|
|
1193
|
+
use_extend = bool(extend and prior_yaml)
|
|
1194
|
+
seeded_scripts = dict(prior_scripts or {})
|
|
1195
|
+
|
|
1196
|
+
# Incremental append: replay known prefix so gap explore starts mid-flow
|
|
1197
|
+
if (
|
|
1198
|
+
replay_prefix
|
|
1199
|
+
and prior_yaml
|
|
1200
|
+
and live
|
|
1201
|
+
and selected
|
|
1202
|
+
and not cloud
|
|
1203
|
+
and status.get("installed")
|
|
1204
|
+
):
|
|
1205
|
+
prefix_yaml = strip_trailing_stop_app(prior_yaml)
|
|
1206
|
+
_p("Replaying prior flow prefix (leaving app open for gap explore)…")
|
|
1207
|
+
prefix_dir = None
|
|
1208
|
+
if run_root is not None:
|
|
1209
|
+
prefix_dir = run_root / "prefix-replay"
|
|
1210
|
+
prefix_dir.mkdir(parents=True, exist_ok=True)
|
|
1211
|
+
prefix_run = await run_flow_yaml(
|
|
1212
|
+
prefix_yaml,
|
|
1213
|
+
device_id=selected,
|
|
1214
|
+
timeout_s=timeout_s,
|
|
1215
|
+
scripts=seeded_scripts or None,
|
|
1216
|
+
progress=_p,
|
|
1217
|
+
device_config=device_config,
|
|
1218
|
+
platform=platform,
|
|
1219
|
+
artifact_dir=prefix_dir,
|
|
1220
|
+
flow_env=flow_env,
|
|
1221
|
+
record_video=False,
|
|
1222
|
+
include_tags=include_tags,
|
|
1223
|
+
exclude_tags=exclude_tags,
|
|
1224
|
+
maestro_config=maestro_config,
|
|
1225
|
+
)
|
|
1226
|
+
if not prefix_run.get("ok"):
|
|
1227
|
+
_p("Prefix replay failed — falling back to full explore + extend codegen.")
|
|
1228
|
+
explore_text = goal
|
|
1229
|
+
codegen_text = goal
|
|
1230
|
+
use_extend = True
|
|
1231
|
+
else:
|
|
1232
|
+
_p("Prefix replay ok — exploring new steps only.")
|
|
1233
|
+
|
|
1234
|
+
# --- Explore phase (discovery LLM) before codegen ---
|
|
1235
|
+
want_explore = bool(explore and discovery is not None)
|
|
1236
|
+
if want_explore and live and selected and not cloud and status.get("installed"):
|
|
1237
|
+
try:
|
|
1238
|
+
exploration = await explore_app(
|
|
1239
|
+
explore_text,
|
|
1240
|
+
app_id=app_id,
|
|
1241
|
+
platform=platform,
|
|
1242
|
+
device_id=selected,
|
|
1243
|
+
profile=discovery,
|
|
1244
|
+
max_steps=explore_steps,
|
|
1245
|
+
step_timeout_s=min(90, max(30, timeout_s // 2)),
|
|
1246
|
+
progress=_p,
|
|
1247
|
+
)
|
|
1248
|
+
except Exception as e: # noqa: BLE001
|
|
1249
|
+
_p(f"Explore failed ({e}); falling back to hierarchy snapshot.")
|
|
1250
|
+
exploration = ExplorationResult(
|
|
1251
|
+
goal=explore_text,
|
|
1252
|
+
app_id=app_id,
|
|
1253
|
+
platform=platform,
|
|
1254
|
+
mode="skipped",
|
|
1255
|
+
notes=[f"explore_error: {e}"],
|
|
1256
|
+
)
|
|
1257
|
+
elif want_explore:
|
|
1258
|
+
# Cloud / no device: plan-only exploration from the goal text
|
|
1259
|
+
try:
|
|
1260
|
+
exploration = await plan_only_explore(
|
|
1261
|
+
goal=explore_text,
|
|
1262
|
+
app_id=app_id,
|
|
1263
|
+
platform=platform,
|
|
1264
|
+
profile=discovery,
|
|
1265
|
+
progress=_p,
|
|
1266
|
+
)
|
|
1267
|
+
except Exception as e: # noqa: BLE001
|
|
1268
|
+
_p(f"Plan-only explore failed ({e}); continuing without it.")
|
|
1269
|
+
|
|
1270
|
+
exploration_prompt = exploration.to_prompt_block()
|
|
1271
|
+
if mem_block:
|
|
1272
|
+
exploration_prompt = (
|
|
1273
|
+
(exploration_prompt + "\n\n" + mem_block).strip()
|
|
1274
|
+
if exploration_prompt
|
|
1275
|
+
else mem_block
|
|
1276
|
+
)
|
|
1277
|
+
if run_root is not None and exploration.mode != "skipped":
|
|
1278
|
+
try:
|
|
1279
|
+
(run_root / "exploration.json").write_text(
|
|
1280
|
+
json.dumps(exploration.to_dict(), indent=2) + "\n",
|
|
1281
|
+
encoding="utf-8",
|
|
1282
|
+
)
|
|
1283
|
+
except OSError:
|
|
1284
|
+
pass
|
|
1285
|
+
|
|
1286
|
+
hierarchy = exploration.final_hierarchy or ""
|
|
1287
|
+
if (
|
|
1288
|
+
not hierarchy
|
|
1289
|
+
and live
|
|
1290
|
+
and adaptive
|
|
1291
|
+
and selected
|
|
1292
|
+
and status.get("installed")
|
|
1293
|
+
and not cloud
|
|
1294
|
+
):
|
|
1295
|
+
_p("Fetching view hierarchy…")
|
|
1296
|
+
hierarchy = await fetch_hierarchy(selected)
|
|
1297
|
+
elif cloud and adaptive and not exploration_prompt:
|
|
1298
|
+
_p("Skipping local hierarchy (cloud provider) — heal uses failure logs only.")
|
|
1299
|
+
|
|
1300
|
+
bundle = await generate_flow_bundle(
|
|
1301
|
+
codegen_text,
|
|
1302
|
+
app_id=app_id,
|
|
1303
|
+
platform=platform,
|
|
1304
|
+
profile=codegen_profile,
|
|
1305
|
+
hierarchy=hierarchy,
|
|
1306
|
+
previous_yaml=prior_yaml if (use_extend or prior_yaml) else "",
|
|
1307
|
+
previous_scripts=seeded_scripts or None,
|
|
1308
|
+
exploration=exploration_prompt,
|
|
1309
|
+
allow_js=allow_js,
|
|
1310
|
+
extend=use_extend,
|
|
1311
|
+
progress=_p,
|
|
1312
|
+
)
|
|
1313
|
+
flow = bundle.flow_yaml
|
|
1314
|
+
scripts = dict(bundle.scripts)
|
|
1315
|
+
codegen_usage = codegen_usage.merged(bundle.usage)
|
|
1316
|
+
# Keep prior companion scripts when extend did not re-emit them
|
|
1317
|
+
for rel, body in seeded_scripts.items():
|
|
1318
|
+
scripts.setdefault(rel, body)
|
|
1319
|
+
|
|
1320
|
+
if expect:
|
|
1321
|
+
flow = ensure_expect_asserts(flow, list(expect))
|
|
1322
|
+
if scripts:
|
|
1323
|
+
_p(f"Maestro bundle ready ({len(scripts)} JS file(s)).")
|
|
1324
|
+
else:
|
|
1325
|
+
_p("Maestro YAML ready.")
|
|
1326
|
+
result: dict[str, Any] = {
|
|
1327
|
+
"success": False,
|
|
1328
|
+
"flow_yaml": flow,
|
|
1329
|
+
"scripts": scripts,
|
|
1330
|
+
"device_id": selected or None,
|
|
1331
|
+
"platform": platform,
|
|
1332
|
+
"provider": provider,
|
|
1333
|
+
"logs": logs,
|
|
1334
|
+
"synthesis_only": False,
|
|
1335
|
+
"maestro_status": status,
|
|
1336
|
+
"exploration": exploration.to_dict() if exploration.mode != "skipped" else None,
|
|
1337
|
+
"explore_usage": exploration.usage.to_dict(),
|
|
1338
|
+
"codegen_usage": codegen_usage.to_dict(),
|
|
1339
|
+
"preflight": preflight_meta or None,
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
can_run = False
|
|
1343
|
+
if live and selected:
|
|
1344
|
+
if cloud:
|
|
1345
|
+
can_run = bool((status.get("cloud") or {}).get("ready"))
|
|
1346
|
+
else:
|
|
1347
|
+
can_run = bool(status.get("installed"))
|
|
1348
|
+
|
|
1349
|
+
if not live or not can_run:
|
|
1350
|
+
result["success"] = True
|
|
1351
|
+
result["synthesis_only"] = True
|
|
1352
|
+
if not live:
|
|
1353
|
+
result["summary"] = "Flow generated (--gen-only; skipped device run)."
|
|
1354
|
+
elif cloud:
|
|
1355
|
+
result["summary"] = (
|
|
1356
|
+
"Flow generated (cloud lab not ready — skipped run). "
|
|
1357
|
+
+ str((status.get("cloud") or {}).get("message") or "")
|
|
1358
|
+
).strip()
|
|
1359
|
+
else:
|
|
1360
|
+
result["summary"] = "Flow generated (no live device / Maestro — skipped run)."
|
|
1361
|
+
_p(result["summary"])
|
|
1362
|
+
return result
|
|
1363
|
+
|
|
1364
|
+
heal_budget = max(0, int(heal or 0))
|
|
1365
|
+
heal_round = 0
|
|
1366
|
+
attempt = 0
|
|
1367
|
+
last_run: dict[str, Any] = {}
|
|
1368
|
+
attempts_meta: list[dict[str, Any]] = []
|
|
1369
|
+
# Order: execute → retry N (same YAML) → heal → repeat
|
|
1370
|
+
while True:
|
|
1371
|
+
passed = False
|
|
1372
|
+
for retry_i in range(max_retries + 1):
|
|
1373
|
+
attempt += 1
|
|
1374
|
+
label = f"heal {heal_round}/{heal_budget}"
|
|
1375
|
+
if max_retries:
|
|
1376
|
+
label += f" retry {retry_i}/{max_retries}"
|
|
1377
|
+
_p(f"Device run attempt {attempt} ({label})…")
|
|
1378
|
+
attempt_dir = None
|
|
1379
|
+
if run_root is not None:
|
|
1380
|
+
attempt_dir = run_root / "attempts" / f"{attempt:02d}"
|
|
1381
|
+
attempt_dir.mkdir(parents=True, exist_ok=True)
|
|
1382
|
+
last_run = await run_flow_yaml(
|
|
1383
|
+
flow,
|
|
1384
|
+
device_id=selected,
|
|
1385
|
+
timeout_s=timeout_s,
|
|
1386
|
+
scripts=scripts,
|
|
1387
|
+
progress=_p,
|
|
1388
|
+
device_config=device_config,
|
|
1389
|
+
platform=platform,
|
|
1390
|
+
artifact_dir=attempt_dir,
|
|
1391
|
+
flow_env=flow_env,
|
|
1392
|
+
record_video=record_video and not cloud,
|
|
1393
|
+
include_tags=include_tags,
|
|
1394
|
+
exclude_tags=exclude_tags,
|
|
1395
|
+
maestro_config=maestro_config,
|
|
1396
|
+
)
|
|
1397
|
+
attempts_meta.append(
|
|
1398
|
+
{
|
|
1399
|
+
"attempt": attempt,
|
|
1400
|
+
"heal_round": heal_round,
|
|
1401
|
+
"retry": retry_i,
|
|
1402
|
+
"ok": bool(last_run.get("ok")),
|
|
1403
|
+
"artifact_dir": last_run.get("artifact_dir"),
|
|
1404
|
+
"error": last_run.get("error"),
|
|
1405
|
+
"build_id": last_run.get("build_id"),
|
|
1406
|
+
"dashboard_url": last_run.get("dashboard_url"),
|
|
1407
|
+
}
|
|
1408
|
+
)
|
|
1409
|
+
result["attempts"] = attempts_meta
|
|
1410
|
+
result["artifact_dir"] = (
|
|
1411
|
+
str(run_root) if run_root else last_run.get("artifact_dir")
|
|
1412
|
+
)
|
|
1413
|
+
if last_run.get("ok"):
|
|
1414
|
+
passed = True
|
|
1415
|
+
break
|
|
1416
|
+
if retry_i < max_retries:
|
|
1417
|
+
_p(f"Flow failed — retrying same YAML ({retry_i + 1}/{max_retries})…")
|
|
1418
|
+
|
|
1419
|
+
if passed:
|
|
1420
|
+
result["success"] = True
|
|
1421
|
+
where = f"cloud:{provider}" if cloud else "device"
|
|
1422
|
+
result["summary"] = f"Maestro flow passed on {where}."
|
|
1423
|
+
result["run"] = {
|
|
1424
|
+
k: last_run.get(k)
|
|
1425
|
+
for k in (
|
|
1426
|
+
"returncode",
|
|
1427
|
+
"stdout",
|
|
1428
|
+
"stderr",
|
|
1429
|
+
"provider",
|
|
1430
|
+
"build_id",
|
|
1431
|
+
"dashboard_url",
|
|
1432
|
+
"status",
|
|
1433
|
+
"artifact_dir",
|
|
1434
|
+
"maestro_junit",
|
|
1435
|
+
"maestro_debug_dir",
|
|
1436
|
+
"maestro_output_dir",
|
|
1437
|
+
"video_url",
|
|
1438
|
+
"media_files",
|
|
1439
|
+
"media_dir",
|
|
1440
|
+
"media_urls",
|
|
1441
|
+
)
|
|
1442
|
+
if k in last_run
|
|
1443
|
+
}
|
|
1444
|
+
_p(result["summary"])
|
|
1445
|
+
if last_run.get("dashboard_url"):
|
|
1446
|
+
_p(f"Dashboard: {last_run['dashboard_url']}")
|
|
1447
|
+
if last_run.get("video_url"):
|
|
1448
|
+
_p(f"Video: {last_run['video_url']}")
|
|
1449
|
+
_persist_selectors(True)
|
|
1450
|
+
return result
|
|
1451
|
+
|
|
1452
|
+
if heal_round >= heal_budget:
|
|
1453
|
+
break
|
|
1454
|
+
heal_round += 1
|
|
1455
|
+
failure = (last_run.get("stderr") or "") + "\n" + (last_run.get("stdout") or "")
|
|
1456
|
+
_p("Flow failed — repairing with LLM…")
|
|
1457
|
+
hierarchy = ""
|
|
1458
|
+
if adaptive and not cloud:
|
|
1459
|
+
hierarchy = await fetch_hierarchy(selected)
|
|
1460
|
+
bundle = await generate_flow_bundle(
|
|
1461
|
+
goal,
|
|
1462
|
+
app_id=app_id,
|
|
1463
|
+
platform=platform,
|
|
1464
|
+
profile=codegen_profile,
|
|
1465
|
+
hierarchy=hierarchy,
|
|
1466
|
+
previous_yaml=flow,
|
|
1467
|
+
previous_scripts=scripts,
|
|
1468
|
+
failure_log=failure,
|
|
1469
|
+
exploration=exploration_prompt,
|
|
1470
|
+
allow_js=allow_js,
|
|
1471
|
+
progress=_p,
|
|
1472
|
+
)
|
|
1473
|
+
flow = bundle.flow_yaml
|
|
1474
|
+
scripts = bundle.scripts
|
|
1475
|
+
codegen_usage = codegen_usage.merged(bundle.usage)
|
|
1476
|
+
result["flow_yaml"] = flow
|
|
1477
|
+
result["scripts"] = scripts
|
|
1478
|
+
result["codegen_usage"] = codegen_usage.to_dict()
|
|
1479
|
+
|
|
1480
|
+
result["success"] = False
|
|
1481
|
+
result["summary"] = "Maestro flow failed after retries/heal attempts."
|
|
1482
|
+
result["error"] = last_run.get("error") or "flow_failed"
|
|
1483
|
+
result["run"] = {
|
|
1484
|
+
k: last_run.get(k)
|
|
1485
|
+
for k in (
|
|
1486
|
+
"returncode",
|
|
1487
|
+
"stdout",
|
|
1488
|
+
"stderr",
|
|
1489
|
+
"provider",
|
|
1490
|
+
"build_id",
|
|
1491
|
+
"dashboard_url",
|
|
1492
|
+
"status",
|
|
1493
|
+
"artifact_dir",
|
|
1494
|
+
"maestro_junit",
|
|
1495
|
+
"maestro_debug_dir",
|
|
1496
|
+
"maestro_output_dir",
|
|
1497
|
+
"video_url",
|
|
1498
|
+
"media_files",
|
|
1499
|
+
"media_dir",
|
|
1500
|
+
"media_urls",
|
|
1501
|
+
)
|
|
1502
|
+
if k in last_run
|
|
1503
|
+
}
|
|
1504
|
+
_p(result["summary"])
|
|
1505
|
+
_persist_selectors(False)
|
|
1506
|
+
return result
|